diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 6aecef6e152..7bf1d8cd43b 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -2,6 +2,10 @@ name: Install Node dependencies description: Installs the Node toolchain and repository dependencies for CI jobs, with optional Electron archive caching. inputs: + cache-pnpm-verification: + description: Restore pnpm's policy-checked lockfile verification record on Linux. + required: false + default: 'true' native-runtime: description: Native runtime to prepare after the script-free install (none, node, or electron). required: false @@ -24,6 +28,15 @@ inputs: default: 'false' outputs: + verification-cache-hit: + description: Whether pnpm's verification record was restored. + value: ${{ steps.verification-cache-restore.outputs.cache-hit }} + verification-cache-path: + description: The small pnpm-owned verification record, without registry metadata. + value: ${{ steps.verification-cache.outputs.path }} + verification-cache-key: + description: Exact verification record key, also used by the isolated PR benchmark. + value: ${{ steps.verification-cache.outputs.key }} node-version: description: Resolved Node.js version used for the install. value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }} @@ -86,6 +99,25 @@ runs: path: ${{ steps.pnpm-store.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }} + - name: Resolve pnpm verification cache + id: verification-cache + if: runner.os == 'Linux' && inputs.cache-pnpm-verification == 'true' + shell: bash + env: + POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }} + run: | + printf 'path=%s/lockfile-verified.jsonl\n' "$(pnpm cache path)" >> "$GITHUB_OUTPUT" + printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH" >> "$GITHUB_OUTPUT" + + - name: Restore pnpm verification record + id: verification-cache-restore + if: steps.verification-cache.outputs.key != '' + continue-on-error: true + uses: actions/cache/restore@v5 + with: + path: ${{ steps.verification-cache.outputs.path }} + key: ${{ steps.verification-cache.outputs.key }} + - name: Validate native runtime shell: bash env: @@ -120,6 +152,15 @@ runs: git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml fi + # pnpm checks the cached record's policy and validity; never bypass verification. + - name: Save pnpm verification record on main + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache-restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v5 + with: + path: ${{ steps.verification-cache.outputs.path }} + key: ${{ steps.verification-cache.outputs.key }} + - name: Resolve Electron package cache id: electron-package-cache if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true' @@ -140,12 +181,19 @@ runs: printf 'version=%s\n' "$(node -p "require('./node_modules/electron/package.json').version")" >> "$GITHUB_OUTPUT" - name: Cache Electron package archive - if: inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true' + if: (github.event_name != 'pull_request' || runner.os != 'Linux') && steps.electron-package-cache.outputs.version != '' uses: actions/cache@v5 with: path: ${{ steps.electron-package-cache.outputs.cache-root }} key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }} + - name: Restore Electron package archive without saving + if: github.event_name == 'pull_request' && runner.os == 'Linux' && steps.electron-package-cache.outputs.version != '' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.electron-package-cache.outputs.cache-root }} + key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }} + # Why cached: `--ignore-scripts` leaves node-pty without build/Release, so # ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime. # The artifacts are ABI-bound, so the key carries the target runtime, the resolved diff --git a/.github/actions/prepare-xterm-dependencies/action.yml b/.github/actions/prepare-xterm-dependencies/action.yml new file mode 100644 index 00000000000..8fa54bd85ae --- /dev/null +++ b/.github/actions/prepare-xterm-dependencies/action.yml @@ -0,0 +1,33 @@ +name: Restore xterm build dependencies +description: Reuse installed dependencies for the pinned upstream checkout. +outputs: + cache-key: + description: Exact dependency cache key. + value: ${{ steps.restore.outputs.cache-primary-key }} + cache-hit: + description: Whether the exact installed dependency cache was restored. + value: ${{ steps.restore.outputs.cache-hit }} +runs: + using: composite + steps: + - id: runtime + shell: bash + run: | + . /etc/os-release + echo "image=$ID-$VERSION_ID" >> "$GITHUB_OUTPUT" + echo "node=$(node --version)" >> "$GITHUB_OUTPUT" + - id: restore + uses: actions/cache/restore@v5 + with: + path: | + ${{ runner.temp }}/xterm-patch-build/upstream/.git + ${{ runner.temp }}/xterm-patch-build/upstream/node_modules + key: xterm-dependencies-v1-${{ runner.os }}-${{ steps.runtime.outputs.image }}-${{ runner.arch }}-${{ steps.runtime.outputs.node }}-${{ hashFiles('config/patches/xterm-upstream.json', 'config/scripts/regenerate-xterm-patches.mjs', 'config/scripts/xterm-patch-text.mjs', '.github/actions/prepare-xterm-dependencies/action.yml') }} + - name: Restore download cache on an installed-dependency miss + if: steps.restore.outputs.cache-hit != 'true' + uses: actions/cache/restore@v5 + with: + path: | + ~/.npm + ${{ runner.temp }}/xterm-patch-build/upstream/.git + key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }} diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml index e579782c651..5fae18f2f84 100644 --- a/.github/workflows/bun-profile-tests.yml +++ b/.github/workflows/bun-profile-tests.yml @@ -2,6 +2,7 @@ name: Bun profile persistence on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'src/**' - 'config/**' @@ -17,6 +18,8 @@ on: - '.github/actions/install-node-dependencies/**' - '.github/workflows/bun-profile-tests.yml' workflow_dispatch: + schedule: + - cron: '30 11 * * *' permissions: contents: read @@ -32,6 +35,8 @@ jobs: timeout-minutes: 5 outputs: should_run: ${{ steps.scope.outputs.should_run }} + qualification: ${{ steps.scope.outputs.qualification }} + runners: ${{ steps.scope.outputs.runners }} steps: - uses: actions/checkout@v6 with: @@ -56,7 +61,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-22.04, ubuntu-24.04-arm, macos-14, macos-15-intel, windows-2022, windows-11-arm] + os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-14","macos-15-intel","windows-2022","windows-11-arm"]') }} runs-on: ${{ matrix.os }} timeout-minutes: 20 env: @@ -82,9 +87,12 @@ jobs: node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018 linux_glibc_floor: - needs: changes - # Missing/failed detection runs the full matrix; manual runs remain unconditional. - if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} strategy: fail-fast: false matrix: @@ -107,9 +115,12 @@ jobs: - run: pnpm test:bun:profile --artifact linux_musl: - needs: changes - # Missing/failed detection runs the full matrix; manual runs remain unconditional. - if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} + needs: [changes, persistence] + # A failed smoke already blocks qualification; missing scope still selects every platform. + if: >- + ${{ !cancelled() && needs.persistence.result == 'success' && + needs.changes.outputs.should_run != 'false' && + needs.changes.outputs.qualification != 'false' }} strategy: fail-fast: false matrix: diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 9eb64a242d7..7309f7e3912 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -54,6 +54,10 @@ jobs: with: native-runtime: node node-version: '24' + cache-electron-package: 'true' + + - name: Populate shared Electron archive + run: node config/scripts/install-electron-package-binary.mjs - uses: ./.github/actions/prepare-git-compatibility @@ -83,6 +87,9 @@ jobs: with: native-runtime: node node-version: '24' + cache-electron-package: 'true' + - name: Populate shared Electron archive + run: node config/scripts/install-electron-package-binary.mjs - name: Verify native cache is usable run: node config/scripts/ensure-native-runtime.mjs --check-only diff --git a/.github/workflows/ci-closed-pr-caches.yml b/.github/workflows/ci-closed-pr-caches.yml new file mode 100644 index 00000000000..38cf5803a14 --- /dev/null +++ b/.github/workflows/ci-closed-pr-caches.yml @@ -0,0 +1,33 @@ +name: Clean closed PR caches + +on: + pull_request_target: + types: [closed] + +permissions: + actions: write + +jobs: + clean: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # No checkout: this runs trusted default-branch code, including for fork PRs. + - uses: actions/github-script@v8 + with: + script: | + const ref = `refs/pull/${context.payload.pull_request.number}/merge` + const caches = await github.paginate(github.rest.actions.getActionsCacheList, { + ...context.repo, ref, per_page: 100 + }) + for (const cache of caches) { + if (cache.ref !== ref) throw new Error(`Unexpected cache ref: ${cache.ref}`) + try { + await github.rest.actions.deleteActionsCacheById({ + ...context.repo, cache_id: cache.id + }) + } catch (error) { + if (error.status !== 404) throw error + } + } + core.info(`Removed ${caches.length} caches scoped to ${ref}`) diff --git a/.github/workflows/ci-pnpm-verification-pilot.yml b/.github/workflows/ci-pnpm-verification-pilot.yml new file mode 100644 index 00000000000..2a35a52fcd0 --- /dev/null +++ b/.github/workflows/ci-pnpm-verification-pilot.yml @@ -0,0 +1,65 @@ +name: pnpm verification cache pilot + +on: + pull_request: + paths: ['.github/workflows/ci-pnpm-verification-pilot.yml'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pnpm-verification-pilot-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + ORCA_BACKGROUND_LAUNCH: '1' + +jobs: + seed: + strategy: + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + id: deps + # PR caches are scoped to the PR merge ref; main never restores them. + - uses: actions/cache/save@v5 + if: steps.deps.outputs.verification-cache-hit != 'true' + with: + path: ${{ steps.deps.outputs.verification-cache-path }} + key: ${{ steps.deps.outputs.verification-cache-key }} + + measure: + needs: seed + name: measure ${{ matrix.runner }} sample ${{ matrix.sample }} cache ${{ matrix.cache }} + strategy: + fail-fast: false + max-parallel: 4 + matrix: + runner: [ubuntu-latest, ubuntu-24.04-arm] + sample: [1, 2, 3] + cache: ['false', 'true'] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + id: deps + with: + cache-pnpm-verification: ${{ matrix.cache }} + - name: Validate treatment and frozen install + env: + CACHE_ENABLED: ${{ matrix.cache }} + CACHE_HIT: ${{ steps.deps.outputs.verification-cache-hit }} + run: | + if [ "$CACHE_ENABLED" = true ]; then test "$CACHE_HIT" = true; fi + git diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml + node -e "require.resolve('vitest'); require.resolve('electron')" diff --git a/.github/workflows/ci-runner-demand.yml b/.github/workflows/ci-runner-demand.yml new file mode 100644 index 00000000000..966813a65e4 --- /dev/null +++ b/.github/workflows/ci-runner-demand.yml @@ -0,0 +1,33 @@ +name: CI runner demand + +on: + schedule: + - cron: '23 4 * * *' + workflow_dispatch: + +permissions: + contents: read + actions: read + +concurrency: + group: ci-runner-demand + cancel-in-progress: false + +jobs: + report: + runs-on: ubuntu-slim + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts + persist-credentials: false + - name: Measure the previous complete 24 hours + env: + GH_TOKEN: ${{ github.token }} + run: node config/scripts/ci-runner-demand.mjs + - uses: actions/upload-artifact@v7 + with: + name: ci-runner-demand-${{ github.run_id }} + path: ci-demand/ + retention-days: 30 diff --git a/.github/workflows/ci-xterm-cache.yml b/.github/workflows/ci-xterm-cache.yml new file mode 100644 index 00000000000..fada7ea8cab --- /dev/null +++ b/.github/workflows/ci-xterm-cache.yml @@ -0,0 +1,55 @@ +name: Shared xterm dependency cache +on: + push: + branches: [main] + paths: + - '.github/workflows/ci-xterm-cache.yml' + - '.github/actions/prepare-xterm-dependencies/**' + - 'config/patches/xterm-upstream.json' + - 'config/scripts/regenerate-xterm-patches.mjs' + - 'config/scripts/xterm-patch-text.mjs' + - 'package.json' + # Refresh after Node patch releases or cache eviction without rebuilding on hits. + schedule: + - cron: '17 5 * * *' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: xterm-cache-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +env: + ORCA_BACKGROUND_LAUNCH: '1' +jobs: + seed: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + package-manager-cache: false + - uses: ./.github/actions/prepare-xterm-dependencies + id: cache + - name: Verify and populate dependencies + if: steps.cache.outputs.cache-hit != 'true' + run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$RUNNER_TEMP/xterm-patch-build" + - uses: actions/cache/save@v5 + if: steps.cache.outputs.cache-hit != 'true' + with: + path: | + ${{ runner.temp }}/xterm-patch-build/upstream/.git + ${{ runner.temp }}/xterm-patch-build/upstream/node_modules + key: ${{ steps.cache.outputs.cache-key }} + - name: Preserve fallback downloads + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.npm + ${{ runner.temp }}/xterm-patch-build/upstream/.git + key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ad9c1ee5139..4177b141d8a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -32,9 +32,8 @@ on: required: false type: string schedule: - # Why: GitHub cron uses UTC; these slots map to 10am and 3pm - # America/Phoenix for the default-branch E2E run. - - cron: '0 17,22 * * *' + # One complete daily reference run; targeted PR coverage remains unchanged. + - cron: '0 17 * * *' jobs: build: @@ -201,7 +200,14 @@ jobs: node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json - name: Run E2E tests (${{ matrix.shard_name }}) - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: ci-shards/results.json + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt --reporter=list,json + + - name: Summarize E2E failures + if: always() + continue-on-error: true + run: node config/scripts/ci-e2e-failure-summary.mjs ci-shards/results.json - name: Upload E2E shard assignment if: always() diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index fa532c9814d..7ec322b0203 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -61,7 +61,10 @@ concurrency: jobs: verify: if: github.event_name == 'pull_request' - runs-on: ubuntu-latest + # Why ARM: 209s of this job is Vitest and nothing here needs x86: no Android SDK, emulator, gradle, + # Hermes or Watchman, no docker, and no artifacts. The Gemfile.lock lists the generic `ruby` + # platform, so frozen bundler installs without an aarch64-linux entry. + runs-on: ubuntu-24.04-arm env: # Why: an unfrozen bundler silently re-resolves when Gemfile.lock drifts @@ -177,7 +180,8 @@ jobs: recording-pin: name: RPC recording pin - runs-on: ubuntu-latest + # Why ARM: pure Node plus git; the golden comparison masks `platform`. + runs-on: ubuntu-24.04-arm defaults: run: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1313b2e5266..27fc5215044 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,5 +1,5 @@ name: PR Checks -run-name: 'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}' +run-name: "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}" on: pull_request: @@ -59,12 +59,15 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 + # Why depth 50 and not 0: every diff below resolves to HEAD^1, so only the merge commit + # and a little slack are needed. Fetching all 8127 refs' commit graph cost ~20s here and + # is charged to the start of all 22 jobs, since they all need this one. + # Why blob:none stays: the sparse tree below is ~7 files, so there are no blobs to + # materialize and no promisor refetch. Measured 9.5s -> 1.6s against 20.7s today. + fetch-depth: 50 filter: blob:none sparse-checkout: | + /config/scripts/git-pull-request-diff-base.mjs /package.json /README.md /docs/readme/ @@ -79,8 +82,9 @@ jobs: - name: Reject new root-level files and folders env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" + run: | + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + node .github/scripts/check-root-directory-entries.mjs "$DIFF_BASE" HEAD # The full Git index retains link targets outside the sparse working tree. - name: Check README local links @@ -99,12 +103,15 @@ jobs: id: filter env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail + # Why HEAD^1 and not --merge-base: HEAD is the pull request merge commit, so its first + # parent is the base side already. Computing a merge base instead would require the + # payload base SHA to be in the graph, which is what forced a full-history checkout. + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" # Why --no-renames: name-only rename detection can report only the destination. # A code file moved under docs/ must still expose its code-side deletion. - CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")" + CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)" echo "Changed paths:" printf '%s\n' "$CHANGED" printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT" @@ -116,8 +123,8 @@ jobs: run: | set -euo pipefail BASE="${{ github.event.pull_request.base.sha }}" - HEAD="${{ github.event.pull_request.head.sha }}" - CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")" + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE")" + CHANGED="$(git diff --name-only --diff-filter=AMCR "$DIFF_BASE" HEAD)" # Source routes are executable contracts so a test can prove exact # authorities, exclusions, and sentinels without evaluating workflow shell. TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)" @@ -131,7 +138,7 @@ jobs: # trigger on IME source rather than on a spec name in some route's list. NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)" echo "native_ime_source_changed=$NATIVE_IME_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" - WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE" "$HEAD")" + WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)" WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)" echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED" @@ -148,20 +155,35 @@ jobs: name: static analysis needs: [code_paths] if: needs.code_paths.outputs.static_analysis == 'true' - runs-on: ubuntu-latest + # Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster -- + # type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke + # 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so + # the whole toolchain resolves. Free for public repositories, same as the typecheck job. + runs-on: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@v6 with: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 - filter: blob:none + # Why depth 50: the gates below diff against HEAD^1, so no merge base is computed and + # the payload base SHA need not be in the graph. + # Why no blob:none here, unlike code_paths: this job checks out all 30,226 files, and + # the filter then forces a second promisor fetch of nearly every blob. Measured 23s + # blobless against ~11s without it. + fetch-depth: 50 persist-credentials: false + # Why two guarded installs: the mixed root+mobile store entry is 537 MB against + # 321 MB for root alone, and restoring it costs 8.6s against 4.6s. Most PRs skip the + # mobile install below, so they were paying 216 MB for packages they never link. The + # root-only key is also the one the hourly warmer reseeds. Only one of these runs. - uses: ./.github/actions/install-node-dependencies + if: needs.code_paths.outputs.mobile_dependencies != 'true' + with: + native-runtime: node + + - uses: ./.github/actions/install-node-dependencies + if: needs.code_paths.outputs.mobile_dependencies == 'true' with: native-runtime: node cache-dependency-path: | @@ -218,9 +240,9 @@ jobs: - name: Check VM runtime rollback compatibility env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \ + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + if git diff --quiet "$DIFF_BASE" HEAD -- \ src/shared/ephemeral-vm-runtime-store.ts \ src/shared/ephemeral-vm-runtime-feature-store.ts \ src/shared/ephemeral-vm-runtime-rollback-projection.ts \ @@ -252,10 +274,10 @@ jobs: background: true env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | # Detection failures run the full check; renames retain the removed input path. - if git diff --name-only --no-renames -z --merge-base "$BASE_SHA" "$HEAD_SHA" > "$RUNNER_TEMP/localization-changes" && + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" && scope="$(node config/scripts/localization-extraction-change-scope.mjs "$RUNNER_TEMP/localization-changes")" && [ "$scope" = false ]; then echo "Localization extraction inputs are unchanged." else @@ -268,11 +290,11 @@ jobs: - name: Boot orcad and round-trip a terminal env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} ORCA_BACKGROUND_LAUNCH: '1' run: | # Detection failures run the smoke; renames retain the removed input path. - if git diff --name-only --no-renames -z --merge-base "$BASE_SHA" "$HEAD_SHA" > "$RUNNER_TEMP/orcad-smoke-changes" && + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/orcad-smoke-changes" && scope="$(node config/scripts/orcad-terminal-smoke-change-scope.mjs "$RUNNER_TEMP/orcad-smoke-changes")" && [ "$scope" = false ]; then echo "Orcad terminal smoke inputs are unchanged." else @@ -413,7 +435,8 @@ jobs: name: Codex index-heal contract needs: [code_paths] if: needs.code_paths.outputs.codex_index_heal_contract == 'true' - runs-on: ubuntu-latest + # Why ARM: @openai/codex ships @openai/codex-linux-arm64. + runs-on: ubuntu-24.04-arm env: CODEX_CLI_VERSION: '0.150.1' @@ -448,7 +471,10 @@ jobs: name: xterm patch sync needs: [code_paths] if: needs.code_paths.outputs.xterm_patch_sync == 'true' - runs-on: ubuntu-latest + # Why ARM: the patch check rebuilds 4 packages x 2 builds and byte-compares against the + # checked-in bundles. Those were generated on Linux x64 and reproduce byte-for-byte on + # darwin-arm64, so the output is neither host-arch nor host-OS dependent. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -456,21 +482,14 @@ jobs: with: persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - - # Why: the check rebuilds every package in the manifest from a pinned upstream - # commit — @xterm/xterm and its three addons, each built twice (once unmodified to - # prove the toolchain still reproduces the published bundles, once patched). Caching - # the npm metadata and the shallow clone keeps the repeated cost to the builds - # themselves; the key is the manifest, so a commit, package or toolchain bump - # invalidates it. - - name: Restore upstream xterm build inputs - uses: actions/cache@v5 + # The generator uses Node built-ins and installs its own upstream toolchain. + - uses: actions/setup-node@v6 with: - path: | - ~/.npm - ${{ runner.temp }}/xterm-patch-build/upstream/.git - key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }} + node-version-file: package.json + package-manager-cache: false + + # Rebuild both pristine and patched bundles; reuse only the pinned toolchain. + - uses: ./.github/actions/prepare-xterm-dependencies - name: Verify xterm patches match the pinned upstream build env: @@ -481,7 +500,8 @@ jobs: name: shell contracts needs: [code_paths] if: needs.code_paths.outputs.shell_contracts == 'true' - runs-on: ubuntu-latest + # Why ARM: fish 4.x is published for noble/arm64 and zsh is in the arm64 archive. + runs-on: ubuntu-24.04-arm # Why: this job's cost is almost entirely package download, and a stalled mirror has # no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this # is generous; it exists so a wedge fails the job instead of holding the whole run @@ -620,16 +640,19 @@ jobs: node-version: '24' test: - needs: [code_paths, test_native_cache] + needs: [code_paths, test_native_cache, static_analysis, typecheck] # Honor cancellation while allowing the optional native-cache primer to skip. if: >- !cancelled() && needs.code_paths.outputs.test == 'true' && + needs.static_analysis.result == 'success' && + needs.typecheck.result == 'success' && (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' runner: ubuntu-24.04-arm + selection_mode: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix # would pay for it on every shard to run one file in whichever shard it landed in. @@ -751,7 +774,8 @@ jobs: name: cross-version wire compatibility needs: [code_paths] if: needs.code_paths.outputs.cross-version-wire == 'true' - runs-on: ubuntu-latest + # Why ARM: source-only: tagged checkout plus in-process vitest, no docker or browser. + runs-on: ubuntu-24.04-arm steps: # Why fetch-depth 0: the harness extracts the newest release tag to skew @@ -783,6 +807,7 @@ jobs: tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts + tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts @@ -790,7 +815,8 @@ jobs: name: managed hooks on Node 18 needs: [code_paths] if: needs.code_paths.outputs.managed_hook_node18 == 'true' - runs-on: ubuntu-latest + # Why ARM: Node 18 publishes linux-arm64; the per-platform runtime files are read as data. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -813,7 +839,7 @@ jobs: package: name: package - needs: [code_paths] + needs: [code_paths, static_analysis, typecheck] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest # Let the serial Docker gates reach their own deadlines and report cleanup failures. @@ -826,7 +852,7 @@ jobs: persist-credentials: false - name: Cache electron-builder downloads - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: ~/.cache/electron-builder key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }} @@ -961,7 +987,7 @@ jobs: package_windows: name: package (windows) - needs: [code_paths] + needs: [code_paths, static_analysis, typecheck] if: needs.code_paths.outputs.package_windows == 'true' runs-on: windows-2022 timeout-minutes: 30 diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index d5030252f88..2fc81c887bb 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -1,6 +1,6 @@ -# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED +# Explicit review identities share workflow concurrency; legacy names use ordered cancellation. name: Pullfrog -run-name: ${{ inputs.name || github.workflow }} +run-name: ${{ inputs.name || github.workflow }}${{ inputs.pull_request_number && format(' | PR {0}', inputs.pull_request_number) || '' }} on: workflow_dispatch: inputs: @@ -11,21 +11,71 @@ on: type: string description: Run name + pull_request_number: + type: string + description: Optional PR identity for cancelling superseded reviews + head_sha: + type: string + description: Optional expected PR head; stale reviews are skipped + permissions: contents: read + pull-requests: read + +concurrency: + group: ${{ inputs.pull_request_number && format('pullfrog-pr-{0}', inputs.pull_request_number) || format('pullfrog-run-{0}', github.run_id) }} + cancel-in-progress: true jobs: + review_scope: + runs-on: ubuntu-slim + permissions: + contents: read + pull-requests: read + actions: write + outputs: + current: ${{ steps.scope.outputs.current }} + number: ${{ steps.scope.outputs.number }} + head: ${{ steps.scope.outputs.head }} + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/pullfrog-review-scope.cjs + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/github-script@v8 + id: scope + with: + script: | + const { reviewScope } = require('./config/scripts/pullfrog-review-scope.cjs') + await reviewScope({ github, context, core }) + pullfrog: + needs: review_scope + if: needs.review_scope.outputs.current == 'true' runs-on: ubuntu-latest permissions: id-token: write contents: read + pull-requests: read steps: - name: Checkout code uses: actions/checkout@v6 with: fetch-depth: 1 + - name: Recheck review head before starting agent + id: freshness + if: needs.review_scope.outputs.number != '' + uses: actions/github-script@v8 + env: + REVIEW_NUMBER: ${{ needs.review_scope.outputs.number }} + REVIEW_HEAD: ${{ needs.review_scope.outputs.head }} + with: + script: | + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: Number(process.env.REVIEW_NUMBER) }) + core.setOutput('current', pr.state === 'open' && pr.head.sha === process.env.REVIEW_HEAD) - name: Run agent + if: needs.review_scope.outputs.number == '' || steps.freshness.outputs.current == 'true' uses: pullfrog/pullfrog@v0 with: prompt: ${{ inputs.prompt }} diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index e6ab98f0d0d..53decc5e7e3 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -19,80 +19,18 @@ jobs: runs-on: ubuntu-slim timeout-minutes: 5 steps: + # Why: release events run this file from the tagged commit, so load the module from the same commit. + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/release-policy.mjs + sparse-checkout-cone-mode: false + persist-credentials: false - name: Enforce release policy uses: actions/github-script@v8 with: script: | - const release = context.payload.release; - const tag = release.tag_name; - const author = release.author?.login; - const number = "(?:0|[1-9][0-9]*)"; - const version = `${number}\\.${number}\\.${number}`; - const stableTag = new RegExp(`^v${version}$`); - const prereleaseTag = new RegExp( - `^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`, - ); - const expectedPrerelease = prereleaseTag.test(tag); - const allowed = author === "github-actions[bot]" && - (stableTag.test(tag) || expectedPrerelease); - const { owner, repo } = context.repo; - - async function restoreLatestStable() { - const releases = await github.paginate(github.rest.repos.listReleases, { - owner, - repo, - per_page: 100, - }); - const stable = releases - .filter((candidate) => - !candidate.draft && - !candidate.prerelease && - candidate.author?.login === "github-actions[bot]" && - stableTag.test(candidate.tag_name), - ) - .sort((left, right) => { - const a = left.tag_name.slice(1).split(".").map(Number); - const b = right.tag_name.slice(1).split(".").map(Number); - return a.reduce((result, part, index) => result || part - b[index], 0); - }) - .at(-1); - if (stable) { - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: stable.id, - make_latest: "true", - }); - } - } - - if (allowed) { - if (release.prerelease !== expectedPrerelease) { - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: release.id, - prerelease: expectedPrerelease, - make_latest: expectedPrerelease ? "false" : "legacy", - }); - } - await restoreLatestStable(); - return; - } - - await github.rest.repos.updateRelease({ - owner, - repo, - release_id: release.id, - draft: true, - prerelease: true, - make_latest: "false", - }); - await restoreLatestStable(); - await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id }); - try { - await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` }); - } catch (error) { - if (error.status !== 404) throw error; - } - core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`); + const { pathToFileURL } = await import("node:url") + const { enforceReleasePolicy } = await import( + pathToFileURL(`${process.env.GITHUB_WORKSPACE}/config/scripts/release-policy.mjs`).href + ) + await enforceReleasePolicy({ github, context, core }) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ccb44c0faa6..2691f4907bc 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -14,19 +14,48 @@ on: default: ubuntu-latest type: string + selection_mode: + description: Shadow validates selection; selected applies it only to draft PRs. + required: false + default: shadow + type: string + permissions: contents: read jobs: + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + shards: ${{ steps.plan.outputs.shards }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Plan unit selection + id: plan + env: + ORCA_UNIT_SELECTION_MODE: ${{ inputs.selection_mode }} + run: node config/scripts/ci-unit-plan.mjs + - uses: actions/upload-artifact@v7 + continue-on-error: true + with: + name: unit-selection-attempt-${{ github.run_attempt }} + path: ci-shards/unit-selection.json + retention-days: 14 + test: - name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} + needs: plan + name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }} runs-on: ${{ inputs.runner }} strategy: fail-fast: false matrix: node: ${{ fromJSON(inputs.node_versions) }} - shard: [1, 2, 3, 4, 5, 6, 7, 8] - shard_total: [8] + shard: ${{ fromJSON(needs.plan.outputs.shards) }} steps: - name: Checkout @@ -43,6 +72,12 @@ jobs: - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs + - uses: actions/download-artifact@v8 + continue-on-error: true + with: + name: unit-selection-attempt-${{ github.run_attempt }} + path: ci-shards/ + - name: Test shard env: ORCA_BALANCE_UNIT_SHARDS: '1' @@ -50,27 +85,7 @@ jobs: run: | export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)" pnpm exec vitest run --config config/vitest.config.ts \ - --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ - --exclude=src/main/daemon/shell-ready.test.ts \ - --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - --exclude=src/main/shell-startup-feature-channel.test.ts \ - --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ - --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ - --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ - --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ - --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ - --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ - --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ - --exclude=src/shared/posix-command-path-lookup.test.ts \ - --exclude=tests/e2e/relay-region-compatibility.unit.test.ts \ - --exclude=tests/e2e/relay-region-correction.unit.test.ts \ - --exclude=tests/e2e/cross-version-wire/** \ - --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + --shard=${{ matrix.shard.index }}/${{ matrix.shard.count }} - name: Upload unit shard assignment if: always() @@ -78,11 +93,40 @@ jobs: continue-on-error: true uses: actions/upload-artifact@v7 with: - name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }} + name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard.index }}-attempt-${{ github.run_attempt }} path: ci-shards/ retention-days: 14 if-no-files-found: warn + selection_evidence: + needs: test + if: ${{ !cancelled() }} + continue-on-error: true + runs-on: ubuntu-slim + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: config/scripts/ci-unit-selection-review.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: '24' + - uses: actions/download-artifact@v8 + with: + pattern: unit-shard-node-*-attempt-${{ github.run_attempt }} + path: unit-evidence/ + - name: Compare selection with full results + continue-on-error: true + run: node config/scripts/ci-unit-selection-review.mjs unit-evidence + - uses: actions/upload-artifact@v7 + if: always() + continue-on-error: true + with: + name: unit-selection-review-attempt-${{ github.run_attempt }} + path: unit-evidence/selection-review.json + retention-days: 30 + relay_integration: name: relay integration node ${{ matrix.node }} strategy: diff --git a/README.md b/README.md index 98efc301c2f..0f337f9e677 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -179,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   MiMo Code logo MiMo Code   @@ -195,6 +196,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Charm logo Charm   Cline logo Cline   Codebuff logo Codebuff   + Freebuff logo Freebuff   Command Code logo Command Code   Continue logo Continue   Droid logo Droid   @@ -231,7 +233,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. -- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) +- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) - **Android:** [Download APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk) --- diff --git a/config/e2e-failure-tracking.json b/config/e2e-failure-tracking.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/config/e2e-failure-tracking.json @@ -0,0 +1 @@ +[] diff --git a/config/electron-builder-pr-linux.config.cjs b/config/electron-builder-pr-linux.config.cjs new file mode 100644 index 00000000000..54056e80996 --- /dev/null +++ b/config/electron-builder-pr-linux.config.cjs @@ -0,0 +1,8 @@ +const release = require('./electron-builder.config.cjs') + +// CI discards these packages after smoke tests; keep release compression unchanged. +module.exports = { + ...release, + deb: { ...release.deb, fpm: [...(release.deb.fpm ?? []), '--deb-compression-level=1'] }, + rpm: { ...release.rpm, fpm: [...(release.rpm.fpm ?? []), '--rpm-compression-level=1'] } +} diff --git a/config/scripts/bun-profile-change-scope.mjs b/config/scripts/bun-profile-change-scope.mjs index c30c4a282ac..7b8b47b69aa 100644 --- a/config/scripts/bun-profile-change-scope.mjs +++ b/config/scripts/bun-profile-change-scope.mjs @@ -8,6 +8,7 @@ import { ORCAD_ENTRY_POINT } from './orcad-entry-build.mjs' import { bunProfileTestPaths } from './bun-profile-test-paths.mjs' +import { bunProfileQualification } from './bun-profile-qualification.mjs' const ROOT = resolve(import.meta.dirname, '../..') const BUILD_SCRIPTS = [ @@ -29,7 +30,9 @@ const ALWAYS_FILES = new Set([ 'tsconfig.json', '.github/workflows/bun-profile-tests.yml', 'config/scripts/bun-profile-change-scope.mjs', - 'config/scripts/bun-profile-change-scope.test.mjs' + 'config/scripts/bun-profile-change-scope.test.mjs', + 'config/scripts/bun-profile-qualification.mjs', + 'config/scripts/bun-profile-qualification.test.mjs' ]) const ALWAYS_PREFIXES = [ '.github/actions/install-node-dependencies/', @@ -110,7 +113,11 @@ export async function classifyBunProfileChanges(changedFiles, collect = collectB reason: matched ? `Runtime or test dependency changed: ${matched}` : 'No Bun inputs changed' } } catch (error) { - return { shouldRun: true, reason: `Dependency graph unavailable: ${String(error)}` } + return { + shouldRun: true, + graphUnavailable: true, + reason: `Dependency graph unavailable: ${String(error)}` + } } } @@ -118,7 +125,14 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) const result = await classifyBunProfileChanges(changedFiles) console.log(result.reason) - const output = `should_run=${String(result.shouldRun)}\n` + let event = {} + try { + event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) + } catch { + // Missing event evidence retains full qualification. + } + const policy = bunProfileQualification(changedFiles, result, event) + const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, output) } else { diff --git a/config/scripts/bun-profile-change-scope.test.mjs b/config/scripts/bun-profile-change-scope.test.mjs index 4b6d1647ffc..e813be93621 100644 --- a/config/scripts/bun-profile-change-scope.test.mjs +++ b/config/scripts/bun-profile-change-scope.test.mjs @@ -78,6 +78,7 @@ it.each([ '.npmrc', 'tsconfig.json', 'config/tsconfig.node.json', + 'config/scripts/bun-profile-qualification.mjs', 'config/patches/node-pty@1.1.0.patch', 'native/windows-registry/src/addon.cc', '.github/actions/install-node-dependencies/action.yml', @@ -140,12 +141,15 @@ it('keeps all ten platform jobs and runs them when detection is skipped or fails expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false) const detect = workflow.jobs.changes.steps.find((step) => step.id === 'scope') expect(detect.run).toContain('git diff --name-only --no-renames -z HEAD^1 HEAD') - let count = 0 - for (const jobName of ['persistence', 'linux_glibc_floor', 'linux_musl']) { + expect(workflow.on.pull_request.types).toContain('ready_for_review') + expect(workflow.on.schedule).toHaveLength(1) + expect(workflow.jobs.persistence.strategy.matrix.os).toContain('needs.changes.outputs.runners') + for (const jobName of ['linux_glibc_floor', 'linux_musl']) { const job = workflow.jobs[jobName] - expect(job.needs).toBe('changes') - expect(job.if).toBe("${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}") - count += job.strategy.matrix.os.length + expect(job.needs).toEqual(['changes', 'persistence']) + expect(job.if).toContain("needs.persistence.result == 'success'") + expect(job.if).toContain("needs.changes.outputs.qualification != 'false'") + expect(job.if).toContain("needs.changes.outputs.should_run != 'false'") + expect(job.strategy.matrix.os).toEqual(['ubuntu-22.04', 'ubuntu-24.04-arm']) } - expect(count).toBe(10) }) diff --git a/config/scripts/bun-profile-qualification.mjs b/config/scripts/bun-profile-qualification.mjs new file mode 100644 index 00000000000..799f459ff85 --- /dev/null +++ b/config/scripts/bun-profile-qualification.mjs @@ -0,0 +1,42 @@ +export const BUN_PERSISTENCE_RUNNERS = [ + 'ubuntu-22.04', + 'ubuntu-24.04-arm', + 'macos-14', + 'macos-15-intel', + 'windows-2022', + 'windows-11-arm' +] + +const QUALIFICATION_PREFIXES = [ + 'config/', + 'native/', + 'resources/', + '.github/', + 'src/main/persistence/', + 'src/main/sqlite/', + 'src/main/orcad/', + 'src/main/providers/', + 'src/main/daemon/', + 'src/main/ssh/', + 'src/relay/', + 'src/shared/child-process/' +] + +export function bunProfileQualification(changedFiles, scope, event = {}) { + const sensitive = changedFiles.some( + (file) => + !file.includes('/') || + QUALIFICATION_PREFIXES.some((prefix) => file.startsWith(prefix)) || + /(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun)(?:[/.-]|$)/i.test(file) + ) + // Only a proven unrelated platform change in a draft may defer qualification. + const full = + event.pull_request?.draft !== true || + changedFiles.length === 0 || + scope.graphUnavailable === true || + sensitive + return { + qualification: full, + runners: full ? BUN_PERSISTENCE_RUNNERS : ['ubuntu-22.04'] + } +} diff --git a/config/scripts/bun-profile-qualification.test.mjs b/config/scripts/bun-profile-qualification.test.mjs new file mode 100644 index 00000000000..9ebc5e437e1 --- /dev/null +++ b/config/scripts/bun-profile-qualification.test.mjs @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import { BUN_PERSISTENCE_RUNNERS, bunProfileQualification } from './bun-profile-qualification.mjs' + +const draft = { pull_request: { draft: true } } +const scope = { shouldRun: true } + +it('defers only platform qualification for ordinary draft runtime changes', () => { + expect( + bunProfileQualification(['src/main/runtime/rpc/methods/example.ts'], scope, draft) + ).toEqual({ + qualification: false, + runners: ['ubuntu-22.04'] + }) +}) + +it.each([ + 'package.json', + 'native/windows-registry/src/addon.cc', + 'config/vitest.config.ts', + 'src/main/ssh/ssh-provider.ts', + 'src/main/providers/local-pty-provider.ts', + 'src/shared/child-process/run-process.ts', + 'src/main/persistence/profile-state/store.ts', + 'src/main/sqlite/database.ts', + 'src/main/orcad/entry.ts', + 'src/main/runtime/windows-terminal.ts', + 'src/shared/linux-glibc.ts', + 'src/main/daemon/entry.ts', + 'src/relay/index.ts', + 'src/main/wsl/runner.ts' +])('retains all platforms for sensitive input %s', (file) => { + expect(bunProfileQualification([file], scope, draft)).toEqual({ + qualification: true, + runners: BUN_PERSISTENCE_RUNNERS + }) +}) + +it('qualifies every ready commit, scheduled/manual runs and incomplete evidence', () => { + const paths = ['src/main/runtime/rpc/methods/example.ts'] + for (const event of [{}, { pull_request: { draft: false } }]) { + expect(bunProfileQualification(paths, scope, event).qualification).toBe(true) + } + expect(bunProfileQualification([], scope, draft).qualification).toBe(true) + expect( + bunProfileQualification(paths, { ...scope, graphUnavailable: true }, draft).qualification + ).toBe(true) +}) diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index a49ad1ddb51..71a512ad407 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -136,9 +136,14 @@ function resolveBase(root, requestedBase) { } export function collectAddedLineRanges(root, requestedBase) { - const base = resolveBase(root, requestedBase) - const mergeBase = runGit(root, ['merge-base', base, 'HEAD']).trim() - const comparisonBase = resolvePullRequestDiffBase(root, mergeBase) + // On a pull_request checkout HEAD is the merge commit, so its first parent is the base side and + // no merge base has to be computed. Resolving it first is what lets CI checkout shallowly: the + // payload base SHA can lag HEAD^1 by any number of commits and need not be in the graph at all. + // Off that ref (local runs) the requested base is an arbitrary branch tip, so the merge base is + // still what isolates this branch's own lines. + const comparisonBase = + resolvePullRequestDiffBase(root, null) ?? + runGit(root, ['merge-base', resolveBase(root, requestedBase), 'HEAD']).trim() const changedFiles = splitNullDelimited( runGit(root, ['diff', '--name-only', '-z', '--diff-filter=ACMRTUB', comparisonBase, '--']) ) @@ -174,7 +179,7 @@ export function collectAddedLineRanges(root, requestedBase) { const lineCount = readFileSync(absolutePath, 'utf8').split(/\r?\n/).length rangesByFile.set(file, [{ start: 1, end: lineCount }]) } - return { base, comparisonBase, rangesByFile } + return { comparisonBase, rangesByFile } } function parseOxlintOutput(stdout, label) { @@ -414,10 +419,12 @@ export function main( root = process.cwd(), requestedBase = process.argv.slice(2).find((argument) => argument !== '--') ) { - const { base, comparisonBase, rangesByFile } = collectAddedLineRanges(root, requestedBase) + const { comparisonBase, rangesByFile } = collectAddedLineRanges(root, requestedBase) const files = [...rangesByFile.keys()] if (files.length === 0) { - console.log(`Changed-code quality gate: no changed JavaScript or TypeScript since ${base}.`) + console.log( + `Changed-code quality gate: no changed JavaScript or TypeScript since ${comparisonBase.slice(0, 12)}.` + ) return 0 } diff --git a/config/scripts/check-root-directory-entries.test.mjs b/config/scripts/check-root-directory-entries.test.mjs index f606d5623fd..6de07a053c7 100644 --- a/config/scripts/check-root-directory-entries.test.mjs +++ b/config/scripts/check-root-directory-entries.test.mjs @@ -228,8 +228,14 @@ describe('root directory guard', () => { expect(guardJob.if).toBeUndefined() expect(guardStep.if).toBeUndefined() - expect(guardJob.steps[0].with['fetch-depth']).toBe(0) + // Why >= 2 rather than 0: the guard compares the merge commit's first parent against the + // merged tree, so it needs both parents present but no history beyond them. Depth 1 would + // leave HEAD^1 unreachable and the guard would fail closed on every run. + expect(guardJob.steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) expect(guardStep.run).toContain('node .github/scripts/check-root-directory-entries.mjs') + // The base side must come from the merge ref, not the event payload, or a shallow checkout + // cannot resolve it. + expect(guardStep.run).toContain('git-pull-request-diff-base.mjs') expect(workflow.jobs.verify.needs).toContain('code_paths') }) }) diff --git a/config/scripts/ci-background-step-barriers.test.mjs b/config/scripts/ci-background-step-barriers.test.mjs index d367d20a7cc..f731d9d08cb 100644 --- a/config/scripts/ci-background-step-barriers.test.mjs +++ b/config/scripts/ci-background-step-barriers.test.mjs @@ -107,7 +107,7 @@ describe('CI background step barriers', () => { it('joins package setup before reading outputs and preserves isolated native probes', () => { const steps = pr.jobs.package.steps // Parallel composites must not race to download their shared cache action on first use. - const cacheAction = steps.findIndex((step) => step.uses === 'actions/cache@v5') + const cacheAction = steps.findIndex((step) => step.uses === 'actions/cache/restore@v5') expect(cacheAction).toBeGreaterThanOrEqual(0) expect(cacheAction).toBeLessThan( steps.findIndex((step) => step.id === 'shutdown-fixture-cache') diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 803563f61c8..322cf982d82 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { expect, it } from 'vitest' import { parse } from 'yaml' +import { BUN_PERSISTENCE_RUNNERS } from './bun-profile-qualification.mjs' const readWorkflow = (name) => parse(readFileSync(new URL(`../../.github/workflows/${name}.yml`, import.meta.url), 'utf8')) @@ -15,7 +16,21 @@ it('warms the same Linux Node runtime the PR shards restore', () => { const primer = readWorkflow('pr').jobs.test_native_cache expect(arm['runs-on']).toBe(primer['runs-on']) expect(arm.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only') - expect(install.with).toEqual(primer.steps.find((step) => step.uses === install.uses).with) + expect(install.with).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with) +}) + +it('populates shared Electron archives on both Linux architectures without changing the Node ABI', () => { + for (const name of ['warm', 'warm-linux-arm']) { + const steps = workflow.jobs[name].steps + const install = steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + expect(install.with['native-runtime']).toBe('node') + expect(install.with['cache-electron-package']).toBe('true') + const populate = steps.find((step) => step.name === 'Populate shared Electron archive') + expect(populate.run).toBe('node config/scripts/install-electron-package-binary.mjs') + expect(steps.indexOf(populate)).toBeGreaterThan(steps.indexOf(install)) + } }) it('publishes incremental state under a key and prefix that new PRs restore', () => { @@ -50,9 +65,8 @@ it('bounds warming to the required platforms and validates changes without grant it('warms and probes both Windows images with the persistence job runtime', () => { const job = workflow.jobs['warm-windows'] - const persistence = readWorkflow('bun-profile-tests').jobs.persistence expect(job.strategy.matrix.os).toEqual( - persistence.strategy.matrix.os.filter((os) => os.startsWith('windows-')) + BUN_PERSISTENCE_RUNNERS.filter((os) => os.startsWith('windows-')) ) expect(job['runs-on']).toBe('${{ matrix.os }}') expect(job.strategy['fail-fast']).toBe(false) diff --git a/config/scripts/ci-closed-pr-caches.test.mjs b/config/scripts/ci-closed-pr-caches.test.mjs new file mode 100644 index 00000000000..44edb5e48df --- /dev/null +++ b/config/scripts/ci-closed-pr-caches.test.mjs @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { expect, it, vi } from 'vitest' +import { parse } from 'yaml' + +const workflow = parse(readFileSync('.github/workflows/ci-closed-pr-caches.yml', 'utf8')) +const script = workflow.jobs.clean.steps[0].with.script +const ref = 'refs/pull/123/merge' + +function run(caches, remove = vi.fn().mockResolvedValue(undefined)) { + const list = vi.fn() + const paginate = vi.fn().mockResolvedValue(caches) + const result = runInNewContext(`(async () => { ${script} })()`, { + context: { repo: { owner: 'owner', repo: 'repo' }, payload: { pull_request: { number: 123 } } }, + github: { + paginate, + rest: { actions: { getActionsCacheList: list, deleteActionsCacheById: remove } } + }, + core: { info: vi.fn() } + }) + return { result, remove, paginate, list } +} + +it('uses default-branch code without checking out a closed PR', () => { + expect(workflow.on).toEqual({ pull_request_target: { types: ['closed'] } }) + expect(workflow.permissions).toEqual({ actions: 'write' }) + expect(workflow.jobs.clean.steps).toHaveLength(1) + expect(workflow.jobs.clean.steps[0].uses).toBe('actions/github-script@v8') +}) + +it('lists and deletes only caches scoped to the closed merge ref', async () => { + const { result, paginate, list, remove } = run([ + { id: 12, ref }, + { id: 13, ref } + ]) + await result + expect(paginate).toHaveBeenCalledWith(list, { owner: 'owner', repo: 'repo', ref, per_page: 100 }) + expect(remove.mock.calls).toEqual([ + [{ owner: 'owner', repo: 'repo', cache_id: 12 }], + [{ owner: 'owner', repo: 'repo', cache_id: 13 }] + ]) +}) + +it('refuses a default-branch cache even if the API returns one', async () => { + const { result, remove } = run([{ id: 12, ref: 'refs/heads/main' }]) + await expect(result).rejects.toThrow('Unexpected cache ref') + expect(remove).not.toHaveBeenCalled() +}) + +it('tolerates an eviction race but surfaces permission failures', async () => { + await expect( + run([{ id: 12, ref }], vi.fn().mockRejectedValue({ status: 404 })).result + ).resolves.toBeUndefined() + await expect( + run([{ id: 12, ref }], vi.fn().mockRejectedValue({ status: 403 })).result + ).rejects.toEqual({ status: 403 }) +}) diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index fad103d8fa6..ce628cc7c45 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -48,7 +48,11 @@ describe('CI dependency download caches', () => { action.runs.steps.findIndex((step) => step.name === 'Install dependencies') ) const saves = action.runs.steps.filter((step) => step.uses === 'actions/cache/save@v5') - expect(saves).toEqual([]) + expect(saves).toHaveLength(1) + expect(saves[0].name).toBe('Save pnpm verification record on main') + expect(saves[0].if).toContain("github.ref == 'refs/heads/main'") + expect(saves[0].if).toContain("github.event_name != 'pull_request'") + expect(saves[0].with.path).toBe('${{ steps.verification-cache.outputs.path }}') }) it('restores Windows packaging downloads from the release cache without a PR upload', () => { @@ -99,8 +103,8 @@ describe('CI dependency download caches', () => { expect(writer.with['restore-keys']).toBeUndefined() expect(writer.with['lookup-only']).toBe(true) expect(release.steps.indexOf(writer)).toBeGreaterThan(release.steps.indexOf(combined)) - // Retain PR fallback saves until a successful release seeds the default-branch entry. - expect(consumer.uses).toBe('actions/cache@v5') + expect(consumer.uses).toBe('actions/cache/restore@v5') + expect(consumer.with['restore-keys'].trim()).toBe('electron-builder-linux-') expect(combined.uses).toBe('actions/cache@v5') expect(linux.eb_cache_path.trim().split('\n')).toEqual([ '~/.cache/electron', @@ -109,6 +113,20 @@ describe('CI dependency download caches', () => { }) }) +it('shares Electron archives with PRs without uploading PR-local copies', () => { + const save = action.runs.steps.find((step) => step.name === 'Cache Electron package archive') + const restore = action.runs.steps.find( + (step) => step.name === 'Restore Electron package archive without saving' + ) + expect(save.if).toContain("github.event_name != 'pull_request' || runner.os != 'Linux'") + expect(restore.if).toContain("github.event_name == 'pull_request' && runner.os == 'Linux'") + expect(save.if).toContain("steps.electron-package-cache.outputs.version != ''") + expect(restore.if).toContain("steps.electron-package-cache.outputs.version != ''") + expect(save.uses).toBe('actions/cache@v5') + expect(restore.uses).toBe('actions/cache/restore@v5') + expect(restore.with).toEqual(save.with) +}) + describe('release install targets', () => { const macCpuFlag = '--cpu=current,x64,arm64' // Both shapes: `run:` steps and steps wrapped in nick-fields/retry (`with.command`). diff --git a/config/scripts/ci-e2e-failure-summary.mjs b/config/scripts/ci-e2e-failure-summary.mjs new file mode 100644 index 00000000000..f95fed20dac --- /dev/null +++ b/config/scripts/ci-e2e-failure-summary.mjs @@ -0,0 +1,92 @@ +import { appendFileSync, readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { trackE2eFailures } from './ci-e2e-failure-tracking.mjs' + +export function e2eFailureSummary(report) { + const failures = [] + let passed = 0 + let skipped = 0 + function visit(suite, parents = [], parentFile) { + const titles = [...parents, suite.title].filter(Boolean) + const file = suite.file ?? parentFile + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + if (test.status === 'expected') { + passed++ + continue + } + if (test.status === 'skipped') { + skipped++ + continue + } + const errors = test.results.flatMap((result) => result.errors ?? []) + failures.push({ + file: spec.file ?? file, + title: [...titles, spec.title].join(' › '), + project: test.projectName, + status: test.status, + message: errors + .map((error) => error.message ?? error.value ?? '') + .join('\n') + .slice(0, 2000) + }) + } + } + for (const child of suite.suites ?? []) { + visit(child, titles, file) + } + } + for (const suite of report.suites ?? []) { + visit(suite) + } + return { passed, skipped, failures, errors: report.errors ?? [] } +} + +export function renderE2eFailures(summary, records = [], now = new Date()) { + const escape = (value) => + String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + const tracked = trackE2eFailures(summary.failures, records, now) + const details = (failure) => + `
${escape(failure.status)}: ${escape(failure.file)} — ${escape(failure.title)}
${escape(failure.message)}
` + return [ + '## E2E results', + '', + `${summary.passed} expected results; ${summary.skipped} skipped; ${summary.failures.length} unexpected/flaky results; ${summary.errors.length} run errors.`, + '', + `### Untracked failures (${tracked.untracked.length})`, + '', + ...tracked.untracked.map(details), + '', + `### Tracked failures (${tracked.known.length})`, + '', + ...tracked.known.flatMap((failure) => [ + details(failure), + `Owner: ${escape(failure.tracking.owner)}; ${escape(failure.tracking.issue)}; expires ${escape(failure.tracking.expires)}.` + ]), + ...(tracked.invalid.length + ? [`${tracked.invalid.length} invalid/expired tracking entries were not used.`] + : []), + ...summary.errors.map((error) => `
${escape(error.message ?? error.value)}
`), + '', + 'Failures retain their original verdict. Repeated failures need a tracked owner, reproduction and review date; do not treat a red baseline as passing.', + '' + ].join('\n') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const summary = e2eFailureSummary(JSON.parse(readFileSync(process.argv[2], 'utf8'))) + const records = JSON.parse( + readFileSync(new URL('../e2e-failure-tracking.json', import.meta.url), 'utf8') + ) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, renderE2eFailures(summary, records)) + } catch (error) { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `E2E report unavailable (${error.code ?? 'invalid report'}); inspect the failing step and traces.\n` + ) + } +} diff --git a/config/scripts/ci-e2e-failure-summary.test.mjs b/config/scripts/ci-e2e-failure-summary.test.mjs new file mode 100644 index 00000000000..5594e4faa56 --- /dev/null +++ b/config/scripts/ci-e2e-failure-summary.test.mjs @@ -0,0 +1,79 @@ +import { expect, it } from 'vitest' +import { e2eFailureSummary, renderE2eFailures } from './ci-e2e-failure-summary.mjs' +import { trackE2eFailures } from './ci-e2e-failure-tracking.mjs' + +it('requires exact failures with an owner, issue and unexpired review date', () => { + const failure = { + file: 'test.spec.ts', + title: 'case', + project: 'electron', + message: 'expected focus failed' + } + const record = { + ...failure, + message: 'expected focus', + owner: '@owner', + issue: 'https://github.com/stablyai/orca/issues/123', + expires: '2026-10-01' + } + const now = new Date('2026-09-28') + expect(trackE2eFailures([failure], [null, record], now)).toMatchObject({ + known: [{ ...failure, tracking: record }], + invalid: [null] + }) + expect(trackE2eFailures([failure], null, now).untracked).toEqual([failure]) + for (const change of [ + { expires: '2026-09-27' }, + { expires: '2026-09-31' }, + { owner: '' }, + { issue: '' }, + { title: 'different' }, + { message: 'different' }, + { project: 'other' } + ]) { + expect(trackE2eFailures([failure], [{ ...record, ...change }], now).untracked).toEqual([ + failure + ]) + } +}) + +it('keeps failure, flaky, skipped and startup-error evidence separate', () => { + const report = { + errors: [{ message: 'startup failed' }], + suites: [ + { + title: 'file', + file: 'tests/e2e/test.spec.ts', + suites: [ + { + title: 'feature', + specs: [ + { + title: 'case', + tests: [ + { status: 'expected' }, + { status: 'skipped' }, + { + status: 'unexpected', + projectName: 'electron-headless', + results: [{ errors: [{ message: '' }] }] + }, + { + status: 'flaky', + results: [{ errors: [{ message: 'first try' }] }, { errors: [] }] + } + ] + } + ] + } + ] + } + ] + } + const summary = e2eFailureSummary(report) + expect(summary).toMatchObject({ passed: 1, skipped: 1, errors: report.errors }) + expect(summary.failures).toHaveLength(2) + expect(summary.failures[0].title).toBe('file › feature › case') + expect(renderE2eFailures(summary)).toContain('<timeout>') + expect(renderE2eFailures(summary)).toContain('startup failed') +}) diff --git a/config/scripts/ci-e2e-failure-tracking.mjs b/config/scripts/ci-e2e-failure-tracking.mjs new file mode 100644 index 00000000000..3415c023ad5 --- /dev/null +++ b/config/scripts/ci-e2e-failure-tracking.mjs @@ -0,0 +1,42 @@ +export function trackE2eFailures(failures, records, now = new Date()) { + const known = [] + const untracked = [] + const invalid = [] + const active = (Array.isArray(records) ? records : [records]).filter((record) => { + if (!record || typeof record !== 'object' || Array.isArray(record)) { + invalid.push(record) + return false + } + const expiry = new Date(`${record.expires}T23:59:59Z`) + const valid = + typeof record.file === 'string' && + typeof record.title === 'string' && + typeof record.message === 'string' && + record.message.length > 0 && + /^@[\w-]+(?:\/[\w-]+)?$/.test(record.owner ?? '') && + /^https:\/\/github\.com\/stablyai\/orca\/issues\/\d+$/.test(record.issue ?? '') && + /^\d{4}-\d{2}-\d{2}$/.test(record.expires ?? '') && + Number.isFinite(expiry.getTime()) && + expiry.toISOString().slice(0, 10) === record.expires && + expiry.getTime() >= now.getTime() + if (!valid) { + invalid.push(record) + } + return valid + }) + for (const failure of failures) { + const record = active.find( + (entry) => + entry.file === failure.file && + entry.title === failure.title && + entry.project === failure.project && + failure.message.includes(entry.message) + ) + if (record) { + known.push({ ...failure, tracking: record }) + } else { + untracked.push(failure) + } + } + return { known, untracked, invalid } +} diff --git a/config/scripts/ci-runner-demand.mjs b/config/scripts/ci-runner-demand.mjs new file mode 100644 index 00000000000..7690ea358db --- /dev/null +++ b/config/scripts/ci-runner-demand.mjs @@ -0,0 +1,100 @@ +import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { runnerDemand, sampleWorkflowRuns } from './ci-runner-metrics.mjs' + +async function api(path, env) { + const response = await fetch( + `${env.GITHUB_API_URL ?? 'https://api.github.com'}/repos/${env.GITHUB_REPOSITORY}/${path}`, + { + headers: { + Authorization: `Bearer ${env.GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + }, + signal: AbortSignal.timeout(30_000) + } + ) + if (!response.ok) { + throw new Error(`Actions API: ${response.status}`) + } + return response.json() +} + +export async function collectRunnerDemand(env = process.env, request = (path) => api(path, env)) { + const end = new Date(env.CI_METRICS_END ?? Date.now()) + end.setUTCMinutes(0, 0, 0) + if (!Number.isFinite(end.getTime())) { + throw new Error('Invalid window end') + } + const runs = [] + for (let hour = 0; hour < 24; hour++) { + const until = new Date(end.getTime() - hour * 3_600_000 - 1000) + const since = new Date(end.getTime() - (hour + 1) * 3_600_000) + const range = `${since.toISOString()}..${until.toISOString()}` + const path = `actions/runs?per_page=100&created=${encodeURIComponent(range)}` + const first = await request(path) + if (first.total_count > 1000) { + throw new Error('Hourly run inventory exceeds API limit; split the interval') + } + runs.push(...first.workflow_runs) + for (let page = 2; page <= Math.ceil(first.total_count / 100); page++) { + runs.push(...(await request(`${path}&page=${page}`)).workflow_runs) + } + } + const samples = sampleWorkflowRuns(runs) + // Bound API concurrency and preserve every selected observation, including zero-job runs. + for (let index = 0; index < samples.length; index += 4) { + await Promise.all( + samples.slice(index, index + 4).map(async (sample) => { + const path = `actions/runs/${sample.run.id}/jobs?per_page=100` + const first = await request(path) + sample.jobs = first.jobs + for (let page = 2; page <= Math.ceil(first.total_count / 100); page++) { + sample.jobs.push(...(await request(`${path}&page=${page}`)).jobs) + } + }) + ) + } + const report = { + start: new Date(end.getTime() - 86_400_000).toISOString(), + end: end.toISOString(), + method: + 'Stratified by workflow/conclusion, six sampled runs per stratum; latest attempts only; incomplete jobs excluded', + ...runnerDemand(runs, samples) + } + return { report, runs, samples } +} + +export function demandMarkdown(report) { + return [ + `## CI demand: ${report.start} to ${report.end}`, + '', + `Estimated full job duration for runs created in this window (not window-clipped occupancy); ${report.sampledRuns}/${report.populationRuns} runs sampled. ${report.method}.`, + '', + '| Workflow | Runs | Runner hours | Cancelled-run hours | Minutes/completed PR run |', + '| --- | ---: | ---: | ---: | ---: |', + ...report.workflows.map( + (row) => + `| ${row.workflow} | ${row.runs} | ${(row.runnerMinutes / 60).toFixed(1)} | ${(row.cancelledRunnerMinutes / 60).toFixed(1)} | ${row.runnerMinutesPerCompletedPrRun?.toFixed(1) ?? '—'} |` + ), + '', + '| Runner labels | Hours | Queue/provisioning p95 minutes |', + '| --- | ---: | ---: |', + ...report.pools.map( + (pool) => + `| ${pool.label} | ${(pool.runnerMinutes / 60).toFixed(1)} | ${pool.queueP95Minutes?.toFixed(1) ?? '—'} |` + ), + '' + ].join('\n') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const evidence = await collectRunnerDemand() + mkdirSync('ci-demand', { recursive: true }) + writeFileSync('ci-demand/evidence.json', JSON.stringify(evidence)) + const markdown = demandMarkdown(evidence.report) + writeFileSync('ci-demand/report.md', markdown) + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown) + } +} diff --git a/config/scripts/ci-runner-metrics.mjs b/config/scripts/ci-runner-metrics.mjs new file mode 100644 index 00000000000..d01ae3d0153 --- /dev/null +++ b/config/scripts/ci-runner-metrics.mjs @@ -0,0 +1,110 @@ +const minutesBetween = (start, end) => { + const value = (Date.parse(end) - Date.parse(start)) / 60_000 + return Number.isFinite(value) ? Math.max(0, value) : 0 +} + +const workflowPath = (run) => run.path.split('@')[0] +const workflowKey = (run) => run.workflow_id ?? workflowPath(run) + +export function weightedPercentile(values, percentile) { + const sorted = [...values].sort((a, b) => a.value - b.value) + const target = sorted.reduce((sum, item) => sum + item.weight, 0) * percentile + let seen = 0 + for (const item of sorted) { + seen += item.weight + if (seen >= target) { + return item.value + } + } + return null +} + +export function runnerDemand(runs, samples) { + const workflows = new Map() + const pools = new Map() + for (const run of runs) { + if (!workflows.has(workflowKey(run))) { + workflows.set(workflowKey(run), { + workflow: workflowPath(run), + runs: 0, + sampledRuns: 0, + runnerMinutes: 0, + cancelledRunnerMinutes: 0, + completedPrRuns: 0, + completedPrRunnerMinutes: 0 + }) + } + workflows.get(workflowKey(run)).runs++ + } + let incompleteJobs = 0 + for (const { run, jobs, weight } of samples) { + const row = workflows.get(workflowKey(run)) + row.sampledRuns++ + for (const job of jobs) { + if (!job.runner_name || !job.started_at) { + continue + } + if (!job.completed_at) { + incompleteJobs++ + continue + } + const duration = minutesBetween(job.started_at, job.completed_at) + const weighted = duration * weight + row.runnerMinutes += weighted + if (run.conclusion === 'cancelled') { + row.cancelledRunnerMinutes += weighted + } + if (run.event === 'pull_request' && ['success', 'failure'].includes(run.conclusion)) { + row.completedPrRunnerMinutes += weighted + } + const label = [...job.labels].sort().join(', ') || 'unknown' + if (!pools.has(label)) { + pools.set(label, { label, runnerMinutes: 0, waits: [] }) + } + const pool = pools.get(label) + pool.runnerMinutes += weighted + if (job.created_at) { + pool.waits.push({ value: minutesBetween(job.created_at, job.started_at), weight }) + } + } + if (run.event === 'pull_request' && ['success', 'failure'].includes(run.conclusion)) { + row.completedPrRuns += weight + } + } + return { + populationRuns: runs.length, + sampledRuns: samples.length, + incompleteJobs, + workflows: [...workflows.values()] + .map((row) => ({ + ...row, + runnerMinutesPerCompletedPrRun: row.completedPrRuns + ? row.completedPrRunnerMinutes / row.completedPrRuns + : null + })) + .sort((a, b) => b.runnerMinutes - a.runnerMinutes), + pools: [...pools.values()] + .map(({ waits, ...pool }) => ({ ...pool, queueP95Minutes: weightedPercentile(waits, 0.95) })) + .sort((a, b) => b.runnerMinutes - a.runnerMinutes) + } +} + +export function sampleWorkflowRuns(runs, perStratum = 6, random = Math.random) { + const groups = new Map() + for (const run of runs) { + const key = `${workflowKey(run)}:${run.conclusion ?? run.status}` + if (!groups.has(key)) { + groups.set(key, []) + } + groups.get(key).push(run) + } + return [...groups.values()].flatMap((group) => { + const shuffled = [...group] + for (let index = shuffled.length - 1; index > 0; index--) { + const target = Math.floor(random() * (index + 1)) + ;[shuffled[index], shuffled[target]] = [shuffled[target], shuffled[index]] + } + const selected = shuffled.slice(0, perStratum) + return selected.map((run) => ({ run, weight: group.length / selected.length })) + }) +} diff --git a/config/scripts/ci-runner-metrics.test.mjs b/config/scripts/ci-runner-metrics.test.mjs new file mode 100644 index 00000000000..ebf45cc7c35 --- /dev/null +++ b/config/scripts/ci-runner-metrics.test.mjs @@ -0,0 +1,95 @@ +import { expect, it } from 'vitest' +import { runnerDemand, sampleWorkflowRuns } from './ci-runner-metrics.mjs' +import { collectRunnerDemand } from './ci-runner-demand.mjs' + +const run = { + id: 1, + path: '.github/workflows/pr.yml', + event: 'pull_request', + conclusion: 'success' +} +const job = { + runner_name: 'hosted', + labels: ['ubuntu-latest'], + created_at: '2026-09-27T00:00:00Z', + started_at: '2026-09-27T00:03:00Z', + completed_at: '2026-09-27T00:08:00Z' +} + +it('separates occupancy, queueing, cancellations and zero-job observations', () => { + const cancelled = { ...run, id: 2, conclusion: 'cancelled' } + const report = runnerDemand( + [run, cancelled], + [ + { run, weight: 2, jobs: [job, { ...job, runner_name: null }] }, + { run: cancelled, weight: 3, jobs: [job, { ...job, completed_at: null }] } + ] + ) + expect(report.workflows[0]).toMatchObject({ + runnerMinutes: 25, + cancelledRunnerMinutes: 15, + runnerMinutesPerCompletedPrRun: 5 + }) + expect(report.pools[0].queueP95Minutes).toBe(3) + expect(report.incompleteJobs).toBe(1) + expect(runnerDemand([run], [{ run, weight: 1, jobs: [] }]).workflows[0].runnerMinutes).toBe(0) +}) + +it('weights each workflow/outcome stratum back to the full inventory', () => { + const runs = Array.from({ length: 20 }, (_, index) => ({ + ...run, + id: index, + conclusion: index < 10 ? 'success' : 'failure' + })) + const sample = sampleWorkflowRuns(runs, 2, () => 0.5) + expect(sample).toHaveLength(4) + expect(sample.reduce((sum, row) => sum + row.weight, 0)).toBe(20) + expect(new Set(sample.map((row) => row.run.id)).size).toBe(4) +}) + +it('paginates jobs and bounds run discovery to 24 complete hours', async () => { + const requests = [] + const result = await collectRunnerDemand( + { CI_METRICS_END: '2026-09-28T04:59:00Z' }, + async (path) => { + requests.push(path) + if (path.includes('/jobs')) { + return { + total_count: 101, + jobs: path.includes('page=2') ? [job] : Array.from({ length: 100 }, () => job) + } + } + return { + total_count: requests.length === 1 ? 1 : 0, + workflow_runs: requests.length === 1 ? [run] : [] + } + } + ) + expect(result.report.start).toBe('2026-09-27T04:00:00.000Z') + expect(result.report.end).toBe('2026-09-28T04:00:00.000Z') + expect(requests.filter((path) => path.startsWith('actions/runs?'))).toHaveLength(24) + expect(result.samples[0].jobs).toHaveLength(101) + expect(result.report.workflows[0].runnerMinutes).toBe(505) +}) + +it('refuses to publish a silently truncated inventory', async () => { + await expect( + collectRunnerDemand({}, async () => ({ total_count: 1001, workflow_runs: [] })) + ).rejects.toThrow('API limit') +}) + +it('groups ref-qualified paths under the stable workflow ID', () => { + const runs = [ + { ...run, id: 1, workflow_id: 42, path: '.github/workflows/pr.yml@main' }, + { ...run, id: 2, workflow_id: 42, path: '.github/workflows/pr.yml@feature' } + ] + const sample = sampleWorkflowRuns(runs, 1, () => 0.5) + expect(sample).toHaveLength(1) + expect(sample[0].weight).toBe(2) + expect( + runnerDemand( + runs, + sample.map((row) => ({ ...row, jobs: [job] })) + ).workflows + ).toMatchObject([{ workflow: '.github/workflows/pr.yml', runs: 2, runnerMinutes: 10 }]) +}) diff --git a/config/scripts/ci-unit-dependency-graph.mjs b/config/scripts/ci-unit-dependency-graph.mjs new file mode 100644 index 00000000000..f491c1749de --- /dev/null +++ b/config/scripts/ci-unit-dependency-graph.mjs @@ -0,0 +1,84 @@ +import { globSync, readFileSync } from 'node:fs' +import { posix, join } from 'node:path' +import ts from 'typescript-api' + +const EXTENSIONS = [ + '', + '.ts', + '.tsx', + '.mjs', + '.js', + '.cjs', + '.json', + '/index.ts', + '/index.tsx', + '/index.js' +] +const INDIRECT_INPUT = + /\b(?:readFile\w*|readdir\w*|glob\w*|spawn\w*|execFile\w*|execSync|runProcess\w*|fork|Worker)\b|\bimport\s*\(\s*[^'"\s]|\brequire\s*\(\s*[^'"\s]|\bnew\s+URL\s*\(/ + +function localPath(file, specifier) { + if (specifier.startsWith('.')) { + return posix.join(posix.dirname(file), specifier) + } + if (specifier.startsWith('@renderer/')) { + return `src/renderer/src/${specifier.slice(10)}` + } + if (specifier.startsWith('@/')) { + return `src/renderer/src/${specifier.slice(2)}` + } + return null +} + +export function buildUnitDependencyGraph(sources) { + const reverse = new Map() + const opaque = new Set() + for (const [file, source] of sources) { + if (INDIRECT_INPUT.test(source) || file.startsWith('config/') || file.startsWith('tests/')) { + opaque.add(file) + } + for (const imported of ts.preProcessFile(source, true, true).importedFiles) { + const path = localPath(file, imported.fileName) + if (path === null) { + continue + } + const resolved = EXTENSIONS.map((extension) => path + extension).find((candidate) => + sources.has(candidate) + ) + if (!resolved) { + opaque.add(file) + continue + } + if (!reverse.has(resolved)) { + reverse.set(resolved, new Set()) + } + reverse.get(resolved).add(file) + } + } + return { reverse, opaque } +} + +export function collectUnitDependencyGraph(root = process.cwd()) { + const files = globSync( + [ + 'src/**/*.{ts,tsx,js,mjs,cjs,json}', + 'config/**/*.{ts,tsx,js,mjs,cjs,json}', + 'tests/**/*.{ts,tsx,js,mjs,cjs,json}' + ], + { cwd: root } + ) + const sources = new Map( + files.map((file) => [file.replaceAll('\\', '/'), readFileSync(join(root, file), 'utf8')]) + ) + return { ...buildUnitDependencyGraph(sources), files: new Set(sources.keys()) } +} + +export function unitConsumers(seeds, reverse) { + const result = new Set(seeds) + for (const file of result) { + for (const consumer of reverse.get(file) ?? []) { + result.add(consumer) + } + } + return result +} diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs new file mode 100644 index 00000000000..ec7101b6ee3 --- /dev/null +++ b/config/scripts/ci-unit-files.mjs @@ -0,0 +1,41 @@ +import { globSync } from 'node:fs' +import { defaultExclude } from 'vitest/config' + +export const UNIT_INCLUDE = [ + 'src/**/*.test.ts', + 'src/**/*.test.tsx', + 'config/scripts/**/*.test.ts', + 'config/scripts/**/*.test.mjs', + 'tests/tools/**/*.test.mjs', + 'tests/e2e/**/*.unit.test.ts' +] + +export const UNIT_EXCLUDE = [ + ...defaultExclude, + 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts', + 'src/main/daemon/shell-ready.test.ts', + 'src/main/daemon/node-pty-fd-leak.test.ts', + '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/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', + 'src/main/terminal-history-fish-session.node-pty.test.ts', + 'src/main/zsh-scoped-histfile.live-shell.test.ts', + 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', + 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', + 'src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts', + 'src/shared/fish-query-reply-child-stdin.node-pty.test.ts', + 'src/shared/pty-reply-echo-shapes.node-pty.test.ts', + 'src/shared/startup-shell-portability.live-shell.test.ts', + 'src/shared/posix-command-path-lookup.test.ts', + 'tests/e2e/relay-region-compatibility.unit.test.ts', + 'tests/e2e/relay-region-correction.unit.test.ts', + 'tests/e2e/cross-version-wire/**' +] + +export function discoverUnitFiles(root = process.cwd()) { + return globSync(UNIT_INCLUDE, { cwd: root, exclude: UNIT_EXCLUDE }) + .map((file) => file.replaceAll('\\', '/')) + .sort() +} diff --git a/config/scripts/ci-unit-plan.mjs b/config/scripts/ci-unit-plan.mjs new file mode 100644 index 00000000000..28eed60f8e8 --- /dev/null +++ b/config/scripts/ci-unit-plan.mjs @@ -0,0 +1,60 @@ +import { appendFileSync, readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { runProcessSync } from './script-child-process.mjs' +import { collectUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' +import { discoverUnitFiles } from './ci-unit-files.mjs' +import { planUnitSelection } from './ci-unit-selection.mjs' +import { readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs' + +export function prepareUnitPlan(env = process.env) { + const files = discoverUnitFiles() + let plan + try { + const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8')) + if (env.GITHUB_EVENT_NAME !== 'pull_request') { + throw new Error('Full reference run') + } + const diff = runProcessSync({ + program: 'git', + args: ['diff', '--name-only', '--no-renames', '-z', 'HEAD^1', 'HEAD'], + maxOutputBytes: 16 * 1024 * 1024 + }) + if (diff.code !== 0 || diff.timedOut) { + throw new Error('Changed paths unavailable') + } + plan = planUnitSelection({ + files, + changed: diff.stdout.split('\0').filter(Boolean), + graph: collectUnitDependencyGraph(), + timings: readTimingBaseline('unit').timings, + event, + mode: env.ORCA_UNIT_SELECTION_MODE + }) + } catch (error) { + plan = { + version: 1, + mode: 'shadow', + selectionAvailable: false, + reason: String(error), + files, + candidateFiles: files, + executionFiles: files, + shards: Array.from({ length: 8 }, (_, index) => ({ index: index + 1, count: 8 })) + } + } + writeAssignment('ci-shards/unit-selection.json', plan) + if (env.GITHUB_OUTPUT) { + appendFileSync(env.GITHUB_OUTPUT, `shards=${JSON.stringify(plan.shards)}\n`) + } + if (env.GITHUB_STEP_SUMMARY) { + appendFileSync( + env.GITHUB_STEP_SUMMARY, + `Unit selection: **${plan.mode}**; ${plan.candidateFiles.length}/${files.length} candidate files; ${plan.shards.length} execution shards. ${plan.reason}\n` + ) + } + return plan +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + prepareUnitPlan() +} diff --git a/config/scripts/ci-unit-plan.test.mjs b/config/scripts/ci-unit-plan.test.mjs new file mode 100644 index 00000000000..1711582b64f --- /dev/null +++ b/config/scripts/ci-unit-plan.test.mjs @@ -0,0 +1,78 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { runProcessSync } from './script-child-process.mjs' + +it.each([true, false])( + 'plans from a real Git diff, with parent evidence available: %s', + (withParent) => { + const root = mkdtempSync(join(tmpdir(), 'orca-unit-plan-')) + const git = (args) => { + const result = runProcessSync({ program: 'git', args, cwd: root }) + expect(result.code, result.stderr).toBe(0) + return result.stdout.trim() + } + try { + mkdirSync(join(root, 'src')) + writeFileSync(join(root, 'src/value.ts'), 'export const value = 1') + writeFileSync(join(root, 'src/consumer.test.ts'), "import './value'") + writeFileSync(join(root, 'src/unrelated.test.ts'), 'export const unrelated = true') + git(['init', '--quiet']) + git(['add', 'src']) + const commit = [ + '-c', + 'user.name=CI Test', + '-c', + 'user.email=ci-test@example.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '--quiet', + '-m', + 'fixture' + ] + git(commit) + if (withParent) { + writeFileSync(join(root, 'src/value.ts'), 'export const value = 2') + git(['add', 'src']) + git(commit) + } + const sourceSha = git(['rev-parse', 'HEAD']) + const eventPath = join(root, 'event.json') + writeFileSync(eventPath, JSON.stringify({ pull_request: { draft: true } })) + const result = runProcessSync({ + program: process.execPath, + args: [fileURLToPath(new URL('./ci-unit-plan.mjs', import.meta.url))], + cwd: root, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_UNIT_SELECTION_MODE: 'selected', + GITHUB_EVENT_NAME: 'pull_request', + GITHUB_EVENT_PATH: eventPath, + GITHUB_SHA: sourceSha, + ORCA_SHARD_SOURCE_SHA: sourceSha, + GITHUB_OUTPUT: join(root, 'outputs'), + GITHUB_STEP_SUMMARY: join(root, 'summary') + } + }) + expect(result.code, result.stderr).toBe(0) + const plan = JSON.parse(readFileSync(join(root, 'ci-shards/unit-selection.json'), 'utf8')) + expect(plan.sourceSha).toBe(sourceSha) + expect(plan.mode).toBe(withParent ? 'selected' : 'shadow') + expect(plan.executionFiles).toEqual( + withParent ? ['src/consumer.test.ts'] : ['src/consumer.test.ts', 'src/unrelated.test.ts'] + ) + expect(plan.reason).toBe( + withParent + ? 'Transitive imports plus indirect-input consumers' + : 'Error: Changed paths unavailable' + ) + expect(plan.shards).toHaveLength(withParent ? 1 : 8) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) diff --git a/config/scripts/ci-unit-selection-review.mjs b/config/scripts/ci-unit-selection-review.mjs new file mode 100644 index 00000000000..ae976a06428 --- /dev/null +++ b/config/scripts/ci-unit-selection-review.mjs @@ -0,0 +1,113 @@ +import { appendFileSync, globSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +export function reviewUnitSelection(records) { + const groups = new Map() + for (const { plan, timing } of records) { + const key = JSON.stringify([ + timing.sourceSha, + timing.runId, + timing.runAttempt, + timing.nodeVersion + ]) + if (!groups.has(key)) { + groups.set(key, []) + } + groups.get(key).push({ plan, timing }) + } + return [...groups.values()].map((group) => { + const { plan, timing: first } = group[0] + const selected = new Set(plan.candidateFiles) + const files = new Set() + const shards = new Set() + const missedFailures = [] + let invalid = false + let omittedMs = 0 + let totalMs = 0 + for (const { plan: other, timing } of group) { + invalid ||= + JSON.stringify(other) !== JSON.stringify(plan) || + !first.sourceSha || + plan.sourceSha !== first.sourceSha || + timing.unhandledErrors !== 0 || + !['passed', 'failed'].includes(timing.status) || + timing.shard.count !== first.shard.count || + shards.has(timing.shard.index) || + timing.shard.index < 1 || + timing.shard.index > first.shard.count + shards.add(timing.shard.index) + for (const [file, duration] of Object.entries(timing.timings)) { + invalid ||= + files.has(file) || + !Number.isFinite(duration) || + duration <= 0 || + !['passed', 'failed', 'skipped'].includes(timing.results?.[file]) + files.add(file) + totalMs += duration + if (!selected.has(file)) { + omittedMs += duration + if (timing.results?.[file] === 'failed') { + missedFailures.push(file) + } + } + } + } + const complete = + !invalid && + shards.size === first.shard.count && + JSON.stringify([...files].sort()) === JSON.stringify([...plan.files].sort()) + return { + sourceSha: first.sourceSha, + runId: first.runId, + runAttempt: first.runAttempt, + nodeVersion: first.nodeVersion, + mode: plan.mode, + completeFullRun: complete && plan.mode === 'shadow', + selectionEvaluated: complete && plan.mode === 'shadow' && plan.selectionAvailable === true, + reason: plan.reason, + missedFailures, + files: files.size, + candidateFiles: selected.size, + measuredWorkerMs: totalMs, + potentiallyOmittedWorkerMs: omittedMs + } + }) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const directory = process.argv[2] + if (!directory) { + throw new Error('Usage: ci-unit-selection-review.mjs ARTIFACT_DIRECTORY') + } + const records = globSync('**/unit-timings.json', { cwd: directory }).map((file) => { + const timing = JSON.parse(readFileSync(join(directory, file), 'utf8')) + const plan = JSON.parse( + readFileSync( + join(directory, file.replace('unit-timings.json', 'unit-selection.json')), + 'utf8' + ) + ) + return { timing, plan } + }) + if (!records.length) { + throw new Error('No unit selection evidence') + } + const review = reviewUnitSelection(records) + writeFileSync(join(directory, 'selection-review.json'), `${JSON.stringify(review, null, 2)}\n`) + const summary = [ + '## Unit selection evidence', + '', + ...review.map( + (row) => + `- Node ${row.nodeVersion}: ${row.completeFullRun ? 'complete full reference' : 'incomplete or selected evidence'}; ${row.selectionEvaluated ? 'selection evaluated' : 'not selection-validation evidence'}; ${row.candidateFiles}/${row.files} candidate files; ${row.missedFailures.length} failures outside selection; ${(row.potentiallyOmittedWorkerMs / 60_000).toFixed(1)} potentially omitted worker-minutes. ${row.reason ?? ''}` + ), + '', + 'Worker time overlaps across processes; it is not runner time. Promote only after representative complete references show no missed failures.', + '' + ].join('\n') + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary) + } + console.log(summary) +} diff --git a/config/scripts/ci-unit-selection-review.test.mjs b/config/scripts/ci-unit-selection-review.test.mjs new file mode 100644 index 00000000000..ff8f4c38568 --- /dev/null +++ b/config/scripts/ci-unit-selection-review.test.mjs @@ -0,0 +1,55 @@ +import { expect, it } from 'vitest' +import { reviewUnitSelection } from './ci-unit-selection-review.mjs' + +const plan = { + sourceSha: 'sha', + mode: 'shadow', + selectionAvailable: true, + files: ['a', 'b'], + candidateFiles: ['a'] +} + +it('does not present full fallback runs as selection-validation evidence', () => { + const records = [record(1, 'a', 'passed'), record(2, 'b', 'passed')].map((row) => ({ + ...row, + plan: { ...plan, selectionAvailable: false } + })) + expect(reviewUnitSelection(records)[0]).toMatchObject({ + completeFullRun: true, + selectionEvaluated: false + }) +}) +const record = (index, file, state) => ({ + plan, + timing: { + sourceSha: 'sha', + runId: '1', + runAttempt: '1', + nodeVersion: '24', + status: state, + unhandledErrors: 0, + shard: { index, count: 2 }, + timings: { [file]: 100 }, + results: { [file]: state } + } +}) + +it('finds omitted failures in a complete failing reference run', () => { + expect( + reviewUnitSelection([record(1, 'a', 'passed'), record(2, 'b', 'failed')])[0] + ).toMatchObject({ completeFullRun: true, missedFailures: ['b'], potentiallyOmittedWorkerMs: 100 }) +}) + +it('does not call incomplete, duplicate, interrupted or selected evidence a full reference', () => { + const a = record(1, 'a', 'passed'), + b = record(2, 'b', 'passed') + for (const rows of [ + [a], + [a, a], + [a, { ...b, timing: { ...b.timing, unhandledErrors: 1 } }], + [a, { ...b, timing: { ...b.timing, status: 'interrupted' } }], + [a, b].map((row) => ({ ...row, plan: { ...plan, mode: 'selected' } })) + ]) { + expect(reviewUnitSelection(rows).every((row) => !row.completeFullRun)).toBe(true) + } +}) diff --git a/config/scripts/ci-unit-selection.mjs b/config/scripts/ci-unit-selection.mjs new file mode 100644 index 00000000000..70894dde0a1 --- /dev/null +++ b/config/scripts/ci-unit-selection.mjs @@ -0,0 +1,62 @@ +import { unitConsumers } from './ci-unit-dependency-graph.mjs' +import { balanceFiles } from './ci-shard-assignment.mjs' + +export function selectUnitFiles(files, changed, graph) { + const full = (reason) => ({ files, reason, full: true }) + if (!changed.length) { + return full('Missing changed-path evidence') + } + if (changed.some((file) => !file.startsWith('src/') || !graph.files.has(file))) { + return full('Global, deleted, renamed or unknown input') + } + for (const file of changed) { + const consumers = unitConsumers([file], graph.reverse) + if (!files.some((test) => consumers.has(test))) { + return full('No proven test coverage for changed inputs') + } + } + const affected = unitConsumers([...changed, ...graph.opaque], graph.reverse) + const selected = files.filter((file) => affected.has(file)) + if (!selected.length) { + return full('No proven test coverage for changed inputs') + } + return { + files: selected, + reason: 'Transitive imports plus indirect-input consumers', + full: false + } +} + +export function planUnitSelection({ files, changed, graph, timings, event, mode = 'shadow' }) { + const candidate = selectUnitFiles(files, changed, graph) + const selected = mode === 'selected' && event?.pull_request?.draft === true && !candidate.full + const executionFiles = selected ? candidate.files : files + const totalMs = balanceFiles(executionFiles, 1, timings).shards[0].durationMs + const count = selected + ? Math.max(1, Math.min(8, Math.ceil(totalMs / 900_000), executionFiles.length)) + : 8 + return { + version: 1, + mode: selected ? 'selected' : 'shadow', + selectionAvailable: !candidate.full, + reason: candidate.reason, + files, + candidateFiles: candidate.files, + executionFiles, + shards: Array.from({ length: count }, (_, index) => ({ index: index + 1, count })) + } +} + +export function auditUnitSelection(plan, results) { + const candidates = new Set(plan.candidateFiles) + const omittedFailures = Object.entries(results) + .filter(([file, state]) => state === 'failed' && !candidates.has(file)) + .map(([file]) => file) + return { + mode: plan.mode, + discovered: plan.files.length, + candidate: candidates.size, + executed: Object.keys(results).length, + omittedFailures + } +} diff --git a/config/scripts/ci-unit-selection.test.mjs b/config/scripts/ci-unit-selection.test.mjs new file mode 100644 index 00000000000..37338cbfc6d --- /dev/null +++ b/config/scripts/ci-unit-selection.test.mjs @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { buildUnitDependencyGraph } from './ci-unit-dependency-graph.mjs' +import { auditUnitSelection, planUnitSelection, selectUnitFiles } from './ci-unit-selection.mjs' + +const sources = new Map( + Object.entries({ + 'src/leaf.ts': 'export const value = 1', + 'src/forward.ts': `export * from './leaf'`, + 'src/consumer.test.ts': `import './forward'`, + 'src/dynamic.test.ts': `import('./leaf')`, + 'src/require.test.ts': `require('./leaf')`, + 'src/scan.test.ts': `import { readFileSync } from 'node:fs'; readFileSync('src/leaf.ts')`, + 'src/indirect.ts': `import(pathFromSettings)`, + 'src/indirect.test.ts': `import './indirect'`, + 'src/unrelated.test.ts': 'export const test = 1', + 'src/renderer/src/view.tsx': 'export const value = 1', + 'src/view.test.ts': `import '@renderer/view'; import '@/view'` + }) +) +const graph = { ...buildUnitDependencyGraph(sources), files: new Set(sources.keys()) } +const files = [...sources.keys()].filter((file) => file.endsWith('.test.ts')).sort() + +describe('conservative unit selection', () => { + it('follows re-exports, literal dynamic imports and requires, retaining indirect readers', () => { + expect(selectUnitFiles(files, ['src/leaf.ts'], graph).files).toEqual([ + 'src/consumer.test.ts', + 'src/dynamic.test.ts', + 'src/indirect.test.ts', + 'src/require.test.ts', + 'src/scan.test.ts' + ]) + }) + + it('resolves renderer aliases and changed tests without executing their source', () => { + expect(selectUnitFiles(files, ['src/renderer/src/view.tsx'], graph).files).toContain( + 'src/view.test.ts' + ) + expect(selectUnitFiles(files, ['src/unrelated.test.ts'], graph).files).toContain( + 'src/unrelated.test.ts' + ) + }) + + it('does not mistake unrelated opaque readers for coverage of a new entry point', () => { + const uncovered = { ...graph, files: new Set([...graph.files, 'src/entry.ts']) } + expect(selectUnitFiles(files, ['src/entry.ts'], uncovered)).toMatchObject({ full: true, files }) + }) + + it.each( + [ + [], + ['src/deleted.ts'], + ['src/deleted.ts', 'src/leaf.ts'], + ['pnpm-lock.yaml'], + ['config/vitest.config.ts'] + ].map((changed) => ({ changed })) + )('runs everything for incomplete/global evidence: $changed', ({ changed }) => { + expect(selectUnitFiles(files, changed, graph)).toMatchObject({ files, full: true }) + }) + + it('keeps full coverage by default and on every non-draft commit', () => { + const base = { files, changed: ['src/leaf.ts'], graph, timings: {} } + for (const event of [ + {}, + { pull_request: { draft: false } }, + { pull_request: { draft: true } } + ]) { + expect(planUnitSelection({ ...base, event }).executionFiles).toEqual(files) + } + expect( + planUnitSelection({ ...base, mode: 'selected', event: { pull_request: { draft: false } } }) + .executionFiles + ).toEqual(files) + const selected = planUnitSelection({ + ...base, + mode: 'selected', + event: { pull_request: { draft: true } } + }) + expect(selected.executionFiles).not.toContain('src/unrelated.test.ts') + expect(selected.shards).toEqual([{ index: 1, count: 1 }]) + }) + + it('records failures that would have been missed while shadow runs remain full', () => { + const plan = planUnitSelection({ files, changed: ['src/leaf.ts'], graph, timings: {} }) + expect( + auditUnitSelection(plan, { + 'src/consumer.test.ts': 'failed', + 'src/unrelated.test.ts': 'failed', + 'src/view.test.ts': 'passed' + }).omittedFailures + ).toEqual(['src/unrelated.test.ts']) + }) +}) diff --git a/config/scripts/ci-unit-sequencer.mjs b/config/scripts/ci-unit-sequencer.mjs index cd55b3343b2..6b580bdb002 100644 --- a/config/scripts/ci-unit-sequencer.mjs +++ b/config/scripts/ci-unit-sequencer.mjs @@ -1,4 +1,5 @@ import { relative } from 'node:path' +import { readFileSync } from 'node:fs' import { BaseSequencer } from 'vitest/node' import { balanceFiles, readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs' @@ -6,12 +7,46 @@ export default class TimingSequencer extends BaseSequencer { async shard(specs) { const { index, count } = this.ctx.config.shard const key = (spec) => relative(this.ctx.config.root, spec.moduleId).replaceAll('\\', '/') + let execution = specs + let selectionReason = 'Full suite: no verified selection plan' + try { + const plan = JSON.parse( + readFileSync( + process.env.ORCA_UNIT_SELECTION_PLAN ?? 'ci-shards/unit-selection.json', + 'utf8' + ) + ) + if ( + plan.version !== 1 || + !plan.sourceSha || + plan.sourceSha !== process.env.ORCA_SHARD_SOURCE_SHA || + JSON.stringify([...plan.files].sort()) !== JSON.stringify(specs.map(key).sort()) || + !Array.isArray(plan.executionFiles) || + plan.executionFiles.some((file) => !plan.files.includes(file)) + ) { + throw new Error('Selection provenance or discovery differs') + } + const allowed = new Set(plan.executionFiles) + if (allowed.size === 0) { + throw new Error('Empty execution selection') + } + execution = specs.filter((spec) => allowed.has(key(spec))) + selectionReason = plan.reason + } catch (error) { + console.log(`Running every discovered unit test: ${error.message}`) + } const baseline = readTimingBaseline('unit') - const assignment = balanceFiles(specs.map(key), count, baseline.timings, baseline.overheadMs) + const assignment = balanceFiles( + execution.map(key), + count, + baseline.timings, + baseline.overheadMs + ) writeAssignment(process.env.ORCA_SHARD_MANIFEST ?? 'ci-shards/unit-assignment.json', { ...assignment, baselineSha256: baseline.baselineSha256, - selectedShard: index + selectedShard: index, + selectionReason }) const selected = new Set(assignment.shards[index - 1].files) return specs.filter((spec) => selected.has(key(spec))) diff --git a/config/scripts/ci-unit-sequencer.test.mjs b/config/scripts/ci-unit-sequencer.test.mjs new file mode 100644 index 00000000000..931a3ccd158 --- /dev/null +++ b/config/scripts/ci-unit-sequencer.test.mjs @@ -0,0 +1,44 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import TimingSequencer from './ci-unit-sequencer.mjs' + +let root +afterEach(() => { + vi.unstubAllEnvs() + if (root) { + rmSync(root, { recursive: true, force: true }) + } +}) + +it.each(['valid', 'stale', 'missing-file', 'missing-artifact'])( + 'preserves complete shard coverage with %s planning evidence', + async (kind) => { + root = mkdtempSync(join(tmpdir(), 'unit-sequencer-')) + const files = ['src/a.test.ts', 'src/b.test.ts', 'src/c.test.ts', 'src/d.test.ts'] + const plan = { + version: 1, + sourceSha: kind === 'stale' ? 'old' : 'current', + files: kind === 'missing-file' ? files.slice(1) : files, + executionFiles: files.slice(0, 2) + } + const planPath = join(root, 'selection.json') + if (kind !== 'missing-artifact') { + writeFileSync(planPath, JSON.stringify(plan)) + } + vi.stubEnv('ORCA_UNIT_SELECTION_PLAN', planPath) + vi.stubEnv('ORCA_SHARD_SOURCE_SHA', 'current') + vi.stubEnv('ORCA_SHARD_MANIFEST', join(root, 'assignment.json')) + const assigned = [] + for (const index of [1, 2]) { + const sequencer = new TimingSequencer({ config: { root, shard: { index, count: 2 } } }) + const specs = files.map((file) => ({ moduleId: join(root, file) })) + assigned.push(...(await sequencer.shard(specs)).map((spec) => spec.moduleId)) + } + expect(assigned.sort()).toEqual( + (kind === 'valid' ? files.slice(0, 2) : files).map((file) => join(root, file)).sort() + ) + expect(new Set(assigned).size).toBe(assigned.length) + } +) diff --git a/config/scripts/ci-unit-timing-reporter.mjs b/config/scripts/ci-unit-timing-reporter.mjs index 58ccc8de768..fa54aa1072a 100644 --- a/config/scripts/ci-unit-timing-reporter.mjs +++ b/config/scripts/ci-unit-timing-reporter.mjs @@ -1,5 +1,7 @@ import { relative } from 'node:path' +import { appendFileSync, readFileSync } from 'node:fs' import { writeAssignment } from './ci-shard-assignment.mjs' +import { auditUnitSelection } from './ci-unit-selection.mjs' export function moduleDuration(diagnostic) { return Math.max( @@ -20,12 +22,19 @@ export default class UnitTimingReporter { } onTestRunEnd(modules, errors, reason) { + const results = Object.fromEntries( + modules.map((module) => [ + relative(this.ctx.config.root, module.moduleId).replaceAll('\\', '/'), + module.state?.() ?? 'unknown' + ]) + ) writeAssignment(process.env.ORCA_UNIT_TIMING_REPORT ?? 'ci-shards/unit-timings.json', { metric: 'module-duration-v1', nodeVersion: process.versions.node, shard: this.ctx.config.shard ?? { index: 1, count: 1 }, status: reason, unhandledErrors: errors.length, + results, timings: Object.fromEntries( modules.map((module) => [ relative(this.ctx.config.root, module.moduleId).replaceAll('\\', '/'), @@ -33,5 +42,23 @@ export default class UnitTimingReporter { ]) ) }) + try { + const plan = JSON.parse( + readFileSync( + process.env.ORCA_UNIT_SELECTION_PLAN ?? 'ci-shards/unit-selection.json', + 'utf8' + ) + ) + const audit = auditUnitSelection(plan, results) + writeAssignment('ci-shards/unit-selection-audit.json', audit) + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `Unit selection (${audit.mode}): ${audit.candidate}/${audit.discovered} candidate files; ${audit.omittedFailures.length} failures outside selection.\n` + ) + } + } catch { + // Timing evidence remains useful when selection planning was unavailable. + } } } diff --git a/config/scripts/electron-runtime-floor.test.ts b/config/scripts/electron-runtime-floor.test.ts index 77a1d4741e0..3506b736398 100644 --- a/config/scripts/electron-runtime-floor.test.ts +++ b/config/scripts/electron-runtime-floor.test.ts @@ -7,10 +7,12 @@ import { describe, expect, it } from 'vitest' * around `gtk_init()` while FontConfig warmed up on a pool thread, and below * glibc 2.41 that frees `environ` under a concurrent `getenv()` — a launch-time * use-after-free on every Ubuntu we support (stablyai/orca#20081). 43.7.0 stops - * freeing the published `environ`. A downgrade past it re-ships that crash, and - * nothing else in the tree would notice. + * freeing the published `environ`. 43.7.0 itself threw `Invalid guestInstanceId` when a + * loaded `` left the DOM, which blanked browser tabs React re-inserted + * (electron/electron#53989); 43.7.4 fixes it. A downgrade past either re-ships the bug, + * and nothing else in the tree would notice. */ -const MINIMUM_ELECTRON_VERSION = '43.7.0' +const MINIMUM_ELECTRON_VERSION = '43.7.4' function parseVersion(specifier: string): [number, number, number] { const match = /(\d+)\.(\d+)\.(\d+)/.exec(specifier) @@ -35,15 +37,17 @@ describe('electron runtime floor', () => { it.each([ ['42.9.0', false], ['43.6.0', false], - ['43.7.0', true], - ['43.7.1', true], + ['43.7.0', false], + ['43.7.3', false], + ['43.7.4', true], + ['43.7.5', true], ['43.8.0', true], ['44.0.0', true] ])('reads %s as meeting the floor: %s', (specifier, expected) => { expect(meetsRuntimeFloor(specifier)).toBe(expected) }) - it('pins Electron at or above the glibc environ-race fix', () => { + it('pins Electron at or above the glibc environ-race and webview-detach fixes', () => { const packageJson = JSON.parse( readFileSync(join(__dirname, '../../package.json'), 'utf-8') ) as { devDependencies: Record } diff --git a/config/scripts/git-pull-request-diff-base.mjs b/config/scripts/git-pull-request-diff-base.mjs index 3e2021a1eb1..0dd5b825ef7 100644 --- a/config/scripts/git-pull-request-diff-base.mjs +++ b/config/scripts/git-pull-request-diff-base.mjs @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process' import process from 'node:process' +import { pathToFileURL } from 'node:url' export function selectPullRequestDiffBase(requestedBase, headParents, eventName) { if (eventName === 'pull_request' && headParents.length >= 2) { @@ -21,3 +22,15 @@ export function resolvePullRequestDiffBase( .split(/\s+/) return selectPullRequestDiffBase(requestedBase, headParents, eventName) } + +// Why a CLI: the workflow's inline gates used `git diff --merge-base "$BASE_SHA" "$HEAD_SHA"`, +// which needs the payload base to be reachable and so forced a full-history checkout. Printing +// the resolved base lets those steps diff against `HEAD^1` with the same logic the Node gates +// already use, instead of each one open-coding it. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const resolved = resolvePullRequestDiffBase(process.cwd(), process.argv[2]) + if (!resolved) { + throw new Error('No diff base: pass the pull request base SHA, or run on a merge ref.') + } + process.stdout.write(`${resolved}\n`) +} diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index af88805b8c4..75f50d15ff6 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -38,6 +38,7 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Claude Agent Teams', 'Cline', 'Codebuff', + 'Freebuff', 'Codex', 'Command Code', 'Cursor', diff --git a/config/scripts/localization-extraction-change-scope.test.mjs b/config/scripts/localization-extraction-change-scope.test.mjs index eb3aae13493..8852965ad30 100644 --- a/config/scripts/localization-extraction-change-scope.test.mjs +++ b/config/scripts/localization-extraction-change-scope.test.mjs @@ -43,12 +43,13 @@ it('preserves deleted and renamed inputs and falls back to extraction on detecti (candidate) => candidate.name === 'Verify localization extraction' ) expect(step.env).toEqual({ - BASE_SHA: '${{ github.event.pull_request.base.sha }}', - HEAD_SHA: '${{ github.event.pull_request.head.sha }}' + BASE_SHA: '${{ github.event.pull_request.base.sha }}' }) - expect(step.run).toContain( - 'git diff --name-only --no-renames -z --merge-base "$BASE_SHA" "$HEAD_SHA"' - ) + // The base side comes from the merge ref's first parent, so the gate needs no merge base and + // works on a shallow checkout. The payload head SHA is no longer read: HEAD is the merged tree + // the gate is actually deciding about. + expect(step.run).toContain('node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA"') + expect(step.run).toContain('git diff --name-only --no-renames -z "$DIFF_BASE" HEAD') expect(step.run).not.toContain('--diff-filter') expect(step.run).toContain('&& [ "$scope" = false ]; then') expect(step.run).toMatch(/else\s+pnpm run verify:localization-extraction\s+fi/) diff --git a/config/scripts/mobile-release-shell-switch-workflow.test.mjs b/config/scripts/mobile-release-shell-switch-workflow.test.mjs index 28b94945cc2..40cc20a95ba 100644 --- a/config/scripts/mobile-release-shell-switch-workflow.test.mjs +++ b/config/scripts/mobile-release-shell-switch-workflow.test.mjs @@ -120,6 +120,8 @@ const BUNDLER_CACHE_PATHS = ['metro-cache', '.expo', 'node_modules/.cache'] const REVIEWED_COMPUTED_PATHS = [ '${{ steps.electron-package-cache.outputs.cache-root }}', '${{ steps.pnpm-store.outputs.path }}', + // Only pnpm's lockfile-verified.jsonl record, never Metro transforms. + '${{ steps.verification-cache.outputs.path }}', "${{ github.event_name != 'pull_request' && 'pnpm' || '' }} store" ] diff --git a/config/scripts/mobile-web-app-agent-history-render.test.mjs b/config/scripts/mobile-web-app-agent-history-render.test.mjs index 08ce37ea6c9..b1c69a88062 100644 --- a/config/scripts/mobile-web-app-agent-history-render.test.mjs +++ b/config/scripts/mobile-web-app-agent-history-render.test.mjs @@ -9,6 +9,7 @@ import { createBundleServer, installShellDouble, readBridgeFaultGrant, + readBridgePagePainted, readBridgeProtocolVersion, readShellCsp } from './mobile-web-app-render-harness.mjs' @@ -110,6 +111,7 @@ let routeChunks = {} let cspHeader = null let bridgeVersion = null let faultGrant = null +let paintName = null beforeAll(async () => { if (!bundles) { @@ -118,6 +120,7 @@ beforeAll(async () => { cspHeader = await readShellCsp() bridgeVersion = await readBridgeProtocolVersion() faultGrant = await readBridgeFaultGrant() + paintName = await readBridgePagePainted() scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-agent-history-')) const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') }) routeChunks = built.routeChunks @@ -227,9 +230,11 @@ async function openRoute(route, awaitText, options = {}) { return opened } -/** Every grant-gated notify the page posted, whole and in order, as the shell received them. */ -function readNotifies(page) { - return page.evaluate(() => globalThis.__orcaRenderCheckNotifies ?? []) +/** Every grant-gated notify the page posted, whole and in order, as the shell received them. The + * paint report is not one: every page posts it once its route screen has a frame. */ +async function readNotifies(page) { + const notifies = await page.evaluate(() => globalThis.__orcaRenderCheckNotifies ?? []) + return notifies.filter((frame) => frame.name !== paintName) } describeRender('the agent-history route in a real browser', () => { diff --git a/config/scripts/mobile-web-app-drawer-render.test.mjs b/config/scripts/mobile-web-app-drawer-render.test.mjs index 051072815a4..22d3b14a1d5 100644 --- a/config/scripts/mobile-web-app-drawer-render.test.mjs +++ b/config/scripts/mobile-web-app-drawer-render.test.mjs @@ -302,9 +302,6 @@ describeDrawer('the device Back key reaching a sheet on the page', () => { host: SHELL_HOST, storage: {}, faultGrant, - // What a shell that knows the lane offers, and the frame it would send. Without the first - // the page posts no claim at all, which is the older-shell arm the unit suites pin. - accepts: [backNames.claim], backFrame: backNames.frame }) await page.goto(`${origin}/`, { waitUntil: 'load' }) diff --git a/config/scripts/mobile-web-app-render-harness.mjs b/config/scripts/mobile-web-app-render-harness.mjs index b7a43815a1e..f07e0c130a1 100644 --- a/config/scripts/mobile-web-app-render-harness.mjs +++ b/config/scripts/mobile-web-app-render-harness.mjs @@ -164,9 +164,9 @@ export async function readBrowserFrameQuality() { } /** - * The page's client-identity placeholder and the `init.accepts` name that unlocks it, read from - * the module that declares both. A rig carrying its own copy would go on passing after the real - * pair moved, which is the whole reason every other constant here is read rather than retyped. + * The page's client-identity placeholder, read from the module that declares it. A rig carrying its + * own copy would go on passing after the real one moved, which is the whole reason every other + * constant here is read rather than retyped. */ export async function readBridgePageClientIdentity() { const source = await readFile( @@ -180,10 +180,7 @@ export async function readBridgePageClientIdentity() { } return match[1] } - return { - placeholder: read('BRIDGE_PAGE_CLIENT_ID'), - accept: read('BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT') - } + return { placeholder: read('BRIDGE_PAGE_CLIENT_ID') } } /** The grant the shell offers every page, read from the same source for the same reason. */ @@ -264,7 +261,6 @@ export function installShellDouble({ grants, pageRoutes = null, pageRouteGrants = null, - accepts = null, backFrame = null, replies, streams = [], @@ -323,9 +319,6 @@ export function installShellDouble({ // Omitted when the caller names none, which is the older-shell case the page falls back // on: an absent field is not an empty one, and the page reads the difference. ...(pageRouteGrants === null ? {} : { pageRouteGrants }), - // Omitted when a check names none, which is the shell that performs no swap and the - // state every other rig in this directory runs in. - ...(accepts === null ? {} : { accepts }), // Omitted for a shell too old to name one, which is the case the page has a panel for. ...(route === null ? {} : { route }), ...(host === null ? {} : { host }), diff --git a/config/scripts/mobile-web-app-render.test.mjs b/config/scripts/mobile-web-app-render.test.mjs index 1cd797d2600..84d3164c7cb 100644 --- a/config/scripts/mobile-web-app-render.test.mjs +++ b/config/scripts/mobile-web-app-render.test.mjs @@ -135,8 +135,7 @@ async function openPage({ shellHost = SHELL_HOST, shellStorage = {}, shellGrants, - shellPageRoutes = null, - shellAccepts = null + shellPageRoutes = null } = {}) { const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) if (shellRoute !== undefined) { @@ -151,8 +150,7 @@ async function openPage({ storage: shellStorage, faultGrant, grants: shellGrants ?? [faultGrant], - pageRoutes: shellPageRoutes, - accepts: shellAccepts + pageRoutes: shellPageRoutes }) } const errors = [] @@ -587,11 +585,7 @@ describeRender('the Route A page in a real browser', () => { }) ) try { - // The one case that advertises the report, because it is the only one asserting on it. - const opened = await openPage({ - shellRoute: { pathname: HOST_ROUTE }, - shellAccepts: [paintName] - }) + const opened = await openPage({ shellRoute: { pathname: HOST_ROUTE } }) await opened.page.goto(`${origin}/`, { waitUntil: 'load' }) await opened.page.waitForFunction( () => document.documentElement.dataset.orcaWebEntry === 'mounted', @@ -639,7 +633,6 @@ describeRender('the Route A page in a real browser', () => { const route = `${HOST_ROUTE}/pr/render-check-tree` const opened = await openPage({ shellRoute: { pathname: route }, - shellAccepts: [paintName], shellPageRoutes: [HOST_ROUTE_PATTERN, '/h/[hostId]/pr/[worktreeId]'] }) await opened.page.goto(`${origin}/`, { waitUntil: 'load' }) diff --git a/config/scripts/mobile-web-app-session-terminal-init.test.mjs b/config/scripts/mobile-web-app-session-terminal-init.test.mjs index a33cf408fa4..2d46fdb15c8 100644 --- a/config/scripts/mobile-web-app-session-terminal-init.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-init.test.mjs @@ -148,8 +148,6 @@ async function openSessionWithTerminalTab() { faultGrant, grants: [faultGrant, ...sessionGrants()], pageRoutes: PAGE_ROUTE_PATTERNS, - // The page claims an identity only against a shell that says it swaps one in. - accepts: [pageClientIdentity.accept], streams: ['session.tabs.subscribe', 'terminal.subscribe'], replies: { 'worktree.show': { worktree: { id: WORKTREE, name: WORKTREE, path: `/tmp/${WORKTREE}` } }, diff --git a/config/scripts/mobile-web-app-terminal-probe-route.mjs b/config/scripts/mobile-web-app-terminal-probe-route.mjs index 0e943d8c113..79996974545 100644 --- a/config/scripts/mobile-web-app-terminal-probe-route.mjs +++ b/config/scripts/mobile-web-app-terminal-probe-route.mjs @@ -57,6 +57,8 @@ import { TerminalWebView } from ${JSON.stringify(componentPath)} export default function TerminalProbeRoute() { const handleRef = useRef(null) + // The terminal frame as React Native laid it out, which is what the session fits. + const frameRef = useRef({ width: 0, height: 0 }) const [mounted, setMounted] = useState(true) const onSelectionCopy = useCallback((text) => { globalThis.__orcaTerminalCopied = text @@ -71,10 +73,11 @@ export default function TerminalProbeRoute() { globalThis.__orcaTerminalEngineErrors = globalThis.__orcaTerminalEngineErrors ?? [] globalThis.__orcaTerminalBeforeInput = [] globalThis.__orcaTerminalProbe = { - init: (cols, rows, data) => handleRef.current?.init(cols, rows, data, false, []), + init: (cols, rows, initialData) => + handleRef.current?.init({ cols, rows, initialData, oscLinks: [], frame: frameRef.current }), write: (data) => handleRef.current?.write(data), selectAll: () => handleRef.current?.doSelectAll(), - measure: () => handleRef.current?.measureFitDimensions(), + fit: () => handleRef.current?.fitDimensions(frameRef.current), awaitReady: () => handleRef.current?.awaitReady(), setMounted: (next) => setMounted(next) } @@ -90,14 +93,21 @@ export default function TerminalProbeRoute() { }, []) return ( - {mounted ? ( - - ) : null} + { + frameRef.current = event.nativeEvent.layout + }} + > + {mounted ? ( + + ) : null} + {/* The shape the terminal's live input takes on the page: xterm's own textarea is inert by the document's design, so this is where typed text arrives. */} diff --git a/config/scripts/mobile-web-app-terminal-render.test.mjs b/config/scripts/mobile-web-app-terminal-render.test.mjs index 3b079bd9cb2..4a08bcece53 100644 --- a/config/scripts/mobile-web-app-terminal-render.test.mjs +++ b/config/scripts/mobile-web-app-terminal-render.test.mjs @@ -480,6 +480,14 @@ describeRender( }) await openProbeTerminal(page) expect(documentChunk, 'the document was served as its own chunk').not.toBe(null) + // Ready is notified with the replay's fit still a frame away. Left pending, that fit + // commits the resized box below, the resize refit then has nothing to do, and there is no + // frame to hold; so the document settles first. Since the terminal is built before + // ready, the fixture returns fast enough to land inside that frame. + await page.evaluate( + () => + new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) await page.evaluate((chunk) => { const state = globalThis.__orcaScheduler @@ -627,14 +635,14 @@ describeRender( await page.close() }, 300_000) - it('measures a fit through the handle and records what beforeinput reports', async () => { + it('fits through the handle from the ready box and records what beforeinput reports', async () => { const { page } = await openTerminal() await openProbeTerminal(page) - // The handle's own round trip: a measure is a command in and a notify back, and on the page - // both halves are direct calls rather than a bridge. Null would mean the document answered - // nothing, or answered a grid too small to fit. - const fit = await page.evaluate(() => globalThis.__orcaTerminalProbe.measure()) + // The fit is the app's own, from the cell box the document put in web-ready, against the + // frame React Native laid out, as the session's is. Null would mean the ready carried no box, + // or a grid too small to fit. + const fit = await page.evaluate(() => globalThis.__orcaTerminalProbe.fit()) expect(fit).not.toBeNull() expect(fit.cols).toBeGreaterThanOrEqual(20) expect(fit.rows).toBeGreaterThanOrEqual(8) diff --git a/config/scripts/mobile-web-bundle-manifest.mjs b/config/scripts/mobile-web-bundle-manifest.mjs index b7efce25ca0..2e9888f69c7 100644 --- a/config/scripts/mobile-web-bundle-manifest.mjs +++ b/config/scripts/mobile-web-bundle-manifest.mjs @@ -68,12 +68,10 @@ export function hashedAsset(bytes, extension) { } } -function readIntegerConstant(source, name) { +function readIntegerConstant(source, name, file = 'src/shared/protocol-version.ts') { const match = new RegExp(`export const ${name} = (\\d+)`).exec(source) if (!match) { - throw new Error( - `[mobile-web-bundle-manifest] ${name} not found in src/shared/protocol-version.ts` - ) + throw new Error(`[mobile-web-bundle-manifest] ${name} not found in ${file}`) } return Number.parseInt(match[1], 10) } @@ -94,6 +92,13 @@ export async function readProtocolWindow() { } } +/** The page's build number, parsed for the same reason the protocol window is. */ +export async function readPageVersion() { + const file = join('src', 'shared', 'mobile-web-bundle', 'manifest-contract.ts') + const source = await readFile(join(projectDir, file), 'utf8') + return readIntegerConstant(source, 'MOBILE_WEB_PAGE_VERSION', file) +} + export async function readDesktopVersion() { const packageJson = JSON.parse(await readFile(join(projectDir, 'package.json'), 'utf8')) if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) { @@ -124,6 +129,7 @@ export async function writeMobileWebBundleTree({ desktopVersion, minCompatibleRuntimeProtocolVersion: protocolWindow.minCompatibleRuntimeProtocolVersion, runtimeProtocolVersion: protocolWindow.runtimeProtocolVersion, + pageVersion: await readPageVersion(), entrypoint: MOBILE_WEB_BUNDLE_ENTRYPOINT, totalBytes: assets.reduce((total, asset) => total + asset.byteLength, 0), assets, diff --git a/config/scripts/mobile-web-bundle-manifest.test.mjs b/config/scripts/mobile-web-bundle-manifest.test.mjs index 3c4d35fd8a7..81c447878a8 100644 --- a/config/scripts/mobile-web-bundle-manifest.test.mjs +++ b/config/scripts/mobile-web-bundle-manifest.test.mjs @@ -1,10 +1,16 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { computeMobileWebBundleBuildId, contentTypeForExtension, hashedAsset, - serializeMobileWebBundleAssets + serializeMobileWebBundleAssets, + sha256Hex, + writeMobileWebBundleTree } from './mobile-web-bundle-manifest.mjs' +import { MobileWebBundleManifestSchema } from '../../src/shared/mobile-web-bundle/manifest-contract.js' describe('computeMobileWebBundleBuildId', () => { const assets = [ @@ -55,3 +61,30 @@ describe('hashedAsset', () => { expect(() => hashedAsset(Buffer.from('body'), 'wasm')).toThrow(/no content type registered/) }) }) + +describe('writeMobileWebBundleTree', () => { + it('stamps the page version the contract names, which the shell floors on', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'mobile-web-bundle-manifest-')) + try { + const bytes = Buffer.from('\n', 'utf8') + const entry = { + path: 'index.html', + bytes, + sha256: sha256Hex(bytes), + byteLength: bytes.byteLength, + contentType: contentTypeForExtension('html') + } + await writeMobileWebBundleTree({ + outDir, + written: [entry], + desktopVersion: '1.0.0', + protocolWindow: { runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 3 } + }) + const manifest = JSON.parse(await readFile(join(outDir, 'manifest.json'), 'utf8')) + expect(Number.isInteger(manifest.pageVersion)).toBe(true) + expect(MobileWebBundleManifestSchema.parse(manifest).pageVersion).toBe(manifest.pageVersion) + } finally { + await rm(outDir, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs index f08a12848fd..e0e978800fc 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs @@ -97,12 +97,12 @@ it('only skips the unchanged smoke after a successful diff and dependency analys ) expect(step.env).toEqual({ BASE_SHA: '${{ github.event.pull_request.base.sha }}', - HEAD_SHA: '${{ github.event.pull_request.head.sha }}', ORCA_BACKGROUND_LAUNCH: '1' }) - expect(step.run).toContain( - 'git diff --name-only --no-renames -z --merge-base "$BASE_SHA" "$HEAD_SHA"' - ) + // See the localization gate: HEAD^1 removes the merge-base computation, so a shallow checkout + // is enough and the payload head SHA is unused. + expect(step.run).toContain('node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA"') + expect(step.run).toContain('git diff --name-only --no-renames -z "$DIFF_BASE" HEAD') expect(step.run).not.toContain('--diff-filter') expect(step.run).toContain('&& [ "$scope" = false ]; then') expect(step.run).toMatch(/else\s+pnpm run smoke:orcad-terminal\s+fi/) diff --git a/config/scripts/package-linux-formats.mjs b/config/scripts/package-linux-formats.mjs index 2f826675885..56c5ed4562f 100644 --- a/config/scripts/package-linux-formats.mjs +++ b/config/scripts/package-linux-formats.mjs @@ -24,7 +24,7 @@ export function linuxFormatArguments({ format, appDirectory, outputDirectory }) } return [ '--config', - 'config/electron-builder.config.cjs', + 'config/electron-builder-pr-linux.config.cjs', '--linux', format, '--x64', diff --git a/config/scripts/package-linux-formats.test.mjs b/config/scripts/package-linux-formats.test.mjs index 6a9eb8c4683..7d187af658d 100644 --- a/config/scripts/package-linux-formats.test.mjs +++ b/config/scripts/package-linux-formats.test.mjs @@ -11,6 +11,7 @@ import { symlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -55,7 +56,7 @@ it('preserves configured hooks, architecture, names, and PR compression with exa }) expect(args).toEqual([ '--config', - 'config/electron-builder.config.cjs', + 'config/electron-builder-pr-linux.config.cjs', '--linux', 'deb', '--x64', @@ -209,3 +210,17 @@ describe('independent Linux package formats', () => { expect(readdirSync(outputDirectory)).toEqual([]) }) }) + +it('changes only CI deb/rpm compression levels without mutating release settings or hooks', () => { + const require = createRequire(import.meta.url) + const release = require('../electron-builder.config.cjs') + const before = { deb: { ...release.deb }, rpm: { ...release.rpm } } + const ci = require('../electron-builder-pr-linux.config.cjs') + expect(release.deb).toEqual(before.deb) + expect(release.rpm).toEqual(before.rpm) + expect(ci).toEqual({ + ...release, + deb: { ...release.deb, fpm: [...(release.deb.fpm ?? []), '--deb-compression-level=1'] }, + rpm: { ...release.rpm, fpm: [...(release.rpm.fpm ?? []), '--rpm-compression-level=1'] } + }) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index ab23c41d75a..98dc914052a 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -152,6 +152,8 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/native-chat/agent-session-journal/', 'src/main/native-chat/agent-session-wire/', 'src/main/runtime/agent-session-record-store', + 'src/main/runtime/agent-session-recovery-capsule', + 'src/shared/agent-session-resume-marker', 'src/main/runtime/rpc/dispatcher', 'src/main/runtime/rpc/methods/agent-launch', 'src/main/runtime/rpc/methods/ai-vault.ts', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 094dadf542e..c23ea0e1140 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -530,7 +530,11 @@ describe('PR Checks skip wiring', () => { ) expect(classify.run).toContain('--diff-filter=ACDMR') expect(classify.run).toContain('--no-renames') - expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') + // HEAD is the merge commit, so HEAD^1 is the base side and no merge base is computed. + // That is what lets this job check out shallowly, which every other job waits on. + expect(classify.run).toContain('node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA"') + expect(classify.run).toContain('"$DIFF_BASE" HEAD') + expect(classify.run).not.toContain('--merge-base "$') expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs') expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"') expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe( @@ -582,12 +586,21 @@ describe('PR Checks skip wiring', () => { it('gates each expensive job on its classifier and cache prerequisite', () => { for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) { - expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths']) + expect(prWorkflow.jobs[jobName].needs, jobName).toEqual( + ['package', 'package_windows'].includes(jobName) + ? ['code_paths', 'static_analysis', 'typecheck'] + : ['code_paths'] + ) expect(prWorkflow.jobs[jobName].if, jobName).toBe( `needs.code_paths.outputs.${jobName} == 'true'` ) } - expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache']) + expect(prWorkflow.jobs.test.needs).toEqual([ + 'code_paths', + 'test_native_cache', + 'static_analysis', + 'typecheck' + ]) expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'") expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'") diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 518cf62b930..4bcb1b4f1af 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -238,7 +238,7 @@ describe('PR E2E gate contract', () => { }) it('scopes detection to the PR range so base drift cannot false-trigger', () => { - expect(filterStep.run).toContain('--merge-base "$BASE" "$HEAD"') + expect(filterStep.run).toMatch(/diff-base\.mjs "\$BASE"[\s\S]*"\$DIFF_BASE" HEAD/) expect(filterStep.run).toContain('set -euo pipefail') }) @@ -444,7 +444,7 @@ describe('PR E2E gate contract', () => { }) it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => { - expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') + expect(rollbackStep.run).toMatch(/diff-base\.mjs "\$BASE_SHA"[\s\S]*"\$DIFF_BASE" HEAD --/) expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts') expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts') expect(selectPrE2eSpecs(['src/shared/ephemeral-vm-recipes.ts'])).toEqual([ diff --git a/config/scripts/pr-ready-check-reuse.mjs b/config/scripts/pr-ready-check-reuse.mjs index 7c319f5554c..57e00a740c2 100644 --- a/config/scripts/pr-ready-check-reuse.mjs +++ b/config/scripts/pr-ready-check-reuse.mjs @@ -1,8 +1,8 @@ import { appendFileSync, readFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' -export function prCheckRunTitle({ number, sourceSha, workflowSha }) { - return `PR ${number} | source ${sourceSha} | workflow ${workflowSha}` +export function prCheckRunTitle({ number, sourceSha, workflowSha, unitMode = 'full' }) { + return `PR ${number} | source ${sourceSha} | workflow ${workflowSha} | unit ${unitMode}` } export function reusablePrCheckRun(runs, identity) { diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index b841aa651fa..1d64542a614 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -45,6 +45,12 @@ describe('ready-for-review required check reuse', () => { it('reuses a completed success only for the identical PR, merge source and workflow', () => { expect(reusablePrCheckRun([passed], identity)).toBe(passed) expect(reusablePrCheckRun([], identity)).toBeUndefined() + expect( + reusablePrCheckRun( + [{ ...passed, display_title: prCheckRunTitle({ ...identity, unitMode: 'selected' }) }], + identity + ) + ).toBeUndefined() for (const key of ['number', 'sourceSha', 'workflowSha', 'headSha', 'runId']) { const changed = key === 'number' ? 43 : key === 'runId' ? '123' : 'd'.repeat(40) expect(reusablePrCheckRun([passed], { ...identity, [key]: changed }), key).toBeUndefined() @@ -121,7 +127,7 @@ describe('ready-for-review required check reuse', () => { it('keeps required skips conditional on proof and leaves advisory routing eligible', () => { expect(workflow['run-name']).toBe( - 'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}' + "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}" ) expect(workflow.on.pull_request.types).toContain('ready_for_review') const detector = workflow.jobs.code_paths diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 54d00b876fe..b08365f5baf 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -1,6 +1,7 @@ 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' @@ -105,15 +106,13 @@ describe('PR workflow parallelism', () => { expect(nodeNextWorkflow.on.schedule).toHaveLength(1) expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull() expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}') - expect(sharedTest.strategy.matrix.shard).toEqual( - Array.from({ length: 8 }, (_, index) => index + 1) - ) - expect(sharedTest.strategy.matrix.shard_total).toEqual([8]) + expect(sharedTest.strategy.matrix.shard).toBe('${{ fromJSON(needs.plan.outputs.shards) }}') + expect(sharedTest.needs).toBe('plan') expect(installStep.with['node-version']).toBe('${{ matrix.node }}') expect(installStep.with['cache-electron-package']).toBe('true') - expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}') + expect(testStep.run).toContain('--shard=${{ matrix.shard.index }}/${{ matrix.shard.count }}') for (const testFile of nativeShellContractFiles) { - expect(testStep.run).toContain(`--exclude=${testFile}`) + expect(UNIT_EXCLUDE).toContain(testFile) } expect(primerInstall.with['native-runtime']).toBe('node') expect(primerInstall.with['node-version']).toBe('24') @@ -339,9 +338,10 @@ describe('PR workflow parallelism', () => { (step) => step.uses === './.github/actions/install-node-dependencies' ) - for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) { + for (const jobName of ['typecheck', 'git_compatibility']) { expect(installFor(jobName).with, jobName).toBeUndefined() } + expect(installFor('xterm_patch_sync')).toBeUndefined() expect(installFor('static_analysis').with['native-runtime']).toBe('node') expect(installFor('shell_contracts').with['native-runtime']).toBe('node') expect(sharedTestInstall.with['native-runtime']).toBe('node') @@ -456,7 +456,9 @@ describe('PR workflow parallelism', () => { expect(electronCacheResolution.if).toBe( "inputs.native-runtime == 'electron' || inputs.cache-electron-package == 'true'" ) - expect(electronCache.if).toBe(electronCacheResolution.if) + expect(electronCache.if).toBe( + "(github.event_name != 'pull_request' || runner.os != 'Linux') && steps.electron-package-cache.outputs.version != ''" + ) expect(electronCache.uses).toBe('actions/cache@v5') expect(electronCache.with.key).toContain('steps.electron-package-cache.outputs.version') expect(dependencyAction.inputs['cache-electron-package'].default).toBe('false') diff --git a/config/scripts/pullfrog-review-scope.cjs b/config/scripts/pullfrog-review-scope.cjs new file mode 100644 index 00000000000..5168d5bd7ef --- /dev/null +++ b/config/scripts/pullfrog-review-scope.cjs @@ -0,0 +1,64 @@ +function reviewNumber(inputs) { + if (/^[1-9]\d*$/.test(inputs.pull_request_number ?? '')) { + return Number(inputs.pull_request_number) + } + const match = + / \| PR ([1-9]\d*)$/.exec(inputs.name ?? '') ?? + /^Review (?:new commits on )?#([1-9]\d*) \[[\w-]+\]$/.exec(inputs.name ?? '') + return match ? Number(match[1]) : null +} + +function reviewRunOrder(runs, number, runId) { + const related = runs.filter((run) => reviewNumber({ name: run.display_title }) === number) + return { + superseded: related.some((run) => run.id > runId), + older: related + .filter( + (run) => + run.id < runId && + ['queued', 'in_progress', 'waiting', 'pending', 'requested'].includes(run.status) + ) + .map((run) => run.id) + } +} + +async function reviewScope({ github, context, core }) { + const inputs = context.payload.inputs ?? {} + const number = reviewNumber(inputs) + core.setOutput('current', 'true') + if (!number || !Number.isSafeInteger(number)) { + return + } + try { + const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number }) + if (pr.state !== 'open' || (inputs.head_sha && inputs.head_sha !== pr.head.sha)) { + core.setOutput('current', 'false') + return + } + core.setOutput('head', pr.head.sha) + core.setOutput('number', String(number)) + const { data } = await github.rest.actions.listWorkflowRuns({ + ...context.repo, + workflow_id: 'pullfrog.yml', + event: 'workflow_dispatch', + per_page: 100 + }) + const order = reviewRunOrder(data.workflow_runs, number, context.runId) + if (order.superseded) { + core.setOutput('current', 'false') + return + } + // Run IDs, unlike scope-job completion order, cannot let an older review cancel a newer one. + for (const runId of order.older) { + try { + await github.rest.actions.cancelWorkflowRun({ ...context.repo, run_id: runId }) + } catch (error) { + core.warning(`Could not cancel older review ${runId}: ${error.message}`) + } + } + } catch (error) { + core.warning(`Review identity unavailable; leaving this task independent: ${error.message}`) + } +} + +module.exports = { reviewNumber, reviewRunOrder, reviewScope } diff --git a/config/scripts/pullfrog-review-scope.test.mjs b/config/scripts/pullfrog-review-scope.test.mjs new file mode 100644 index 00000000000..2a3f902da87 --- /dev/null +++ b/config/scripts/pullfrog-review-scope.test.mjs @@ -0,0 +1,109 @@ +import { expect, it, vi } from 'vitest' +import scope from './pullfrog-review-scope.cjs' + +it('groups a current review by PR while leaving other agent tasks independent', async () => { + const get = vi.fn(async () => ({ data: { state: 'open', head: { sha: 'current' } } })) + const core = { setOutput: vi.fn(), warning: vi.fn() } + const context = { + repo: { owner: 'stablyai', repo: 'orca' }, + runId: 7, + payload: { inputs: { name: 'Review #42 [abc]' } } + } + const github = { + rest: { + pulls: { get }, + actions: { listWorkflowRuns: async () => ({ data: { workflow_runs: [] } }) } + } + } + await scope.reviewScope({ core, context, github }) + expect(get).toHaveBeenCalledWith({ owner: 'stablyai', repo: 'orca', pull_number: 42 }) + expect(core.setOutput).toHaveBeenCalledWith('head', 'current') + get.mockClear() + await scope.reviewScope({ + core, + github, + context: { ...context, payload: { inputs: { name: 'Investigate #42' } } } + }) + expect(get).not.toHaveBeenCalled() + expect(core.setOutput).toHaveBeenLastCalledWith('current', 'true') +}) + +it('coalesces only explicit or recognized PR review identities', () => { + expect(scope.reviewNumber({ name: 'Review #23532 [85jpk]' })).toBe(23532) + expect(scope.reviewNumber({ name: 'Review new commits on #22727 [24lpq]' })).toBe(22727) + expect(scope.reviewNumber({ pull_request_number: '123' })).toBe(123) + for (const name of ['Fix #123', 'Review #12; echo test', 'Review #123', '', 'Review #0 [abc]']) { + expect(scope.reviewNumber({ name })).toBeNull() + } +}) + +it('skips closed or explicitly stale reviews and tolerates lookup failure', async () => { + for (const data of [ + { state: 'closed', head: { sha: 'a' } }, + { state: 'open', head: { sha: 'b' } } + ]) { + const core = { setOutput: vi.fn(), warning: vi.fn() } + await scope.reviewScope({ + core, + context: { + repo: {}, + runId: 1, + payload: { inputs: { pull_request_number: '1', head_sha: 'a' } } + }, + github: { rest: { pulls: { get: async () => ({ data }) } } } + }) + expect(core.setOutput).toHaveBeenCalledWith('current', 'false') + } + const core = { setOutput: vi.fn(), warning: vi.fn() } + await scope.reviewScope({ + core, + context: { repo: {}, runId: 2, payload: { inputs: { pull_request_number: '1' } } }, + github: { + rest: { + pulls: { + get: async () => { + throw new Error('offline') + } + } + } + } + }) + expect(core.setOutput).toHaveBeenCalledWith('current', 'true') + expect(core.warning).toHaveBeenCalled() +}) + +it('a delayed older scope cannot cancel or replace a newer review', async () => { + const runs = [ + { id: 6, display_title: 'Review #42 [a]', status: 'in_progress' }, + { id: 8, display_title: 'Custom review | PR 42', status: 'queued' }, + { id: 5, display_title: 'Investigate #42', status: 'in_progress' }, + { id: 4, display_title: 'Review #43 [other]', status: 'in_progress' } + ] + for (const runId of [7, 9]) { + const cancelWorkflowRun = vi.fn(async () => ({})) + const core = { setOutput: vi.fn(), warning: vi.fn() } + await scope.reviewScope({ + context: { + repo: { owner: 'stablyai', repo: 'orca' }, + runId, + payload: { inputs: { name: 'Review #42 [current]' } } + }, + core, + github: { + rest: { + pulls: { get: async () => ({ data: { state: 'open', head: { sha: 'head' } } }) }, + actions: { + listWorkflowRuns: async () => ({ data: { workflow_runs: runs } }), + cancelWorkflowRun + } + } + } + }) + if (runId === 7) { + expect(cancelWorkflowRun).not.toHaveBeenCalled() + expect(core.setOutput).toHaveBeenCalledWith('current', 'false') + } else { + expect(cancelWorkflowRun.mock.calls.map(([args]) => args.run_id)).toEqual([6, 8]) + } + } +}) diff --git a/config/scripts/regenerate-xterm-patches.mjs b/config/scripts/regenerate-xterm-patches.mjs index 672be8bee61..c67c6eb6622 100644 --- a/config/scripts/regenerate-xterm-patches.mjs +++ b/config/scripts/regenerate-xterm-patches.mjs @@ -236,6 +236,7 @@ function ensureDependencies(upstreamRoot, manifest) { const stamp = path.join(upstreamRoot, 'node_modules', '.orca-xterm-install-stamp') const want = `${manifest.upstream.commit}\n${statSync(lockfile).size}\n` if (existsSync(stamp) && readFileSync(stamp, 'utf8') === want) { + assertToolchain(upstreamRoot, manifest) return } run('npm', ['ci'], { diff --git a/config/scripts/release-policy.mjs b/config/scripts/release-policy.mjs new file mode 100644 index 00000000000..4733e79f2b4 --- /dev/null +++ b/config/scripts/release-policy.mjs @@ -0,0 +1,138 @@ +// Enforced by .github/workflows/release-policy.yml on release events. + +const BOT_LOGIN = 'github-actions[bot]' +const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com' +const NUMBER = '(?:0|[1-9][0-9]*)' +const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}` +const STABLE_TAG = new RegExp(`^v${VERSION}$`) +const RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`) +const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`) + +export function isPrereleaseTag(tag) { + return RC_TAG.test(tag) || MOBILE_TAG.test(tag) +} + +export function compareStableTags(left, right) { + const a = left.slice(1).split('.').map(Number) + const b = right.slice(1).split('.').map(Number) + return a.reduce((result, part, index) => result || part - b[index], 0) +} + +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Why: Cut Release creates the annotated tag and its `release: vX` commit, but the +// release object keeps whoever created the draft first, which can be a person. +export async function isCutByReleasePipeline(github, { owner, repo }, tag) { + if (!STABLE_TAG.test(tag) && !RC_TAG.test(tag)) { + return false + } + let ref + try { + ;({ data: ref } = await github.rest.git.getRef({ owner, repo, ref: `tags/${tag}` })) + } catch (error) { + if (error.status === 404) { + return false + } + throw error + } + if (ref.object.type !== 'tag') { + return false + } + const { data: tagObject } = await github.rest.git.getTag({ + owner, + repo, + tag_sha: ref.object.sha + }) + if (tagObject.tagger?.email !== BOT_EMAIL || tagObject.object.type !== 'commit') { + return false + } + const { data: commit } = await github.rest.git.getCommit({ + owner, + repo, + commit_sha: tagObject.object.sha + }) + const subject = commit.message.split('\n')[0] + return new RegExp(`^release: ${escapeRegExp(tag)}(?: \\[rc-slot:[^\\]]+\\])?$`).test(subject) +} + +async function isAuthorizedRelease(github, repoRef, release) { + const tag = release.tag_name + if (!STABLE_TAG.test(tag) && !isPrereleaseTag(tag)) { + return false + } + return release.author?.login === BOT_LOGIN || isCutByReleasePipeline(github, repoRef, tag) +} + +// Why: the highest authorized stable release must be Latest; GitHub's own pick +// (and the old bot-author-only filter) can move it to an older version. +export async function restoreLatestStable(github, repoRef) { + const releases = await github.paginate(github.rest.repos.listReleases, { + ...repoRef, + per_page: 100 + }) + const candidates = releases + .filter((release) => !release.draft && !release.prerelease && STABLE_TAG.test(release.tag_name)) + .sort((left, right) => compareStableTags(right.tag_name, left.tag_name)) + for (const release of candidates) { + if (!(await isAuthorizedRelease(github, repoRef, release))) { + continue + } + await github.rest.repos.updateRelease({ + ...repoRef, + release_id: release.id, + make_latest: 'true' + }) + return release.tag_name + } + return null +} + +export async function enforceReleasePolicy({ github, context, core }) { + const { release, action } = context.payload + const tag = release.tag_name + const author = release.author?.login + const repoRef = context.repo + const expectedPrerelease = isPrereleaseTag(tag) + + if (await isAuthorizedRelease(github, repoRef, release)) { + if (release.prerelease !== expectedPrerelease) { + await github.rest.repos.updateRelease({ + ...repoRef, + release_id: release.id, + prerelease: expectedPrerelease, + make_latest: expectedPrerelease ? 'false' : 'legacy' + }) + } + await restoreLatestStable(github, repoRef) + return + } + + // Why: editing notes on a live release is not a publication; deleting on edit + // took down the v1.4.216 release and its tag. + if (action !== 'published') { + core.warning( + `Release ${tag} by ${author || 'unknown'} is outside release policy; left as-is on "${action}".` + ) + return + } + + await github.rest.repos.updateRelease({ + ...repoRef, + release_id: release.id, + draft: true, + prerelease: true, + make_latest: 'false' + }) + await restoreLatestStable(github, repoRef) + await github.rest.repos.deleteRelease({ ...repoRef, release_id: release.id }) + try { + await github.rest.git.deleteRef({ ...repoRef, ref: `tags/${tag}` }) + } catch (error) { + if (error.status !== 404) { + throw error + } + } + core.warning(`Deleted unauthorized release ${tag} created by ${author || 'unknown'}.`) +} diff --git a/config/scripts/release-policy.test.mjs b/config/scripts/release-policy.test.mjs new file mode 100644 index 00000000000..6478f6bb0c4 --- /dev/null +++ b/config/scripts/release-policy.test.mjs @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest' +import { enforceReleasePolicy, restoreLatestStable } from './release-policy.mjs' + +const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com' +const repoRef = { owner: 'stablyai', repo: 'orca' } + +function notFound() { + return Object.assign(new Error('Not Found'), { status: 404 }) +} + +// tags: { [tag]: { tagger, subject } } for annotated tags; missing tags 404. +function createGithub({ releases = [], tags = {} } = {}) { + const rest = { + repos: { + listReleases: vi.fn(), + updateRelease: vi.fn(async () => ({ data: {} })), + deleteRelease: vi.fn(async () => ({ data: {} })) + }, + git: { + getRef: vi.fn(async ({ ref }) => { + const tag = ref.replace(/^tags\//, '') + if (!tags[tag]) { + throw notFound() + } + return { data: { object: { type: 'tag', sha: `tag-${tag}` } } } + }), + getTag: vi.fn(async ({ tag_sha }) => { + const tag = tag_sha.replace(/^tag-/, '') + return { + data: { + tagger: { email: tags[tag].tagger ?? BOT_EMAIL }, + object: { type: 'commit', sha: `commit-${tag}` } + } + } + }), + getCommit: vi.fn(async ({ commit_sha }) => { + const tag = commit_sha.replace(/^commit-/, '') + return { data: { message: `${tags[tag].subject ?? `release: ${tag}`}\n` } } + }), + deleteRef: vi.fn(async () => ({ data: {} })) + } + } + return { rest, paginate: vi.fn(async () => releases) } +} + +function release(tag, author, overrides = {}) { + return { + id: Number(tag.replace(/\D/g, '')), + tag_name: tag, + author: { login: author }, + draft: false, + prerelease: false, + ...overrides + } +} + +function run(github, payloadRelease, action) { + const core = { warning: vi.fn() } + const context = { payload: { release: payloadRelease, action }, repo: repoRef } + return enforceReleasePolicy({ github, context, core }).then(() => core) +} + +describe('release policy', () => { + it('keeps a pipeline-cut release whose draft a person created', async () => { + const r216 = release('v1.4.216', 'Jinwoo-H') + const github = createGithub({ + releases: [r216, release('v1.4.214', 'github-actions[bot]')], + tags: { 'v1.4.216': {}, 'v1.4.214': {} } + }) + + await run(github, r216, 'edited') + + expect(github.rest.repos.deleteRelease).not.toHaveBeenCalled() + expect(github.rest.git.deleteRef).not.toHaveBeenCalled() + expect(github.rest.repos.updateRelease).toHaveBeenCalledWith({ + ...repoRef, + release_id: r216.id, + make_latest: 'true' + }) + }) + + it('never deletes an unauthorized release on edit', async () => { + const qa = release('v1.4.300', 'someone') + const github = createGithub({ releases: [qa] }) + + const core = await run(github, qa, 'edited') + + expect(github.rest.repos.deleteRelease).not.toHaveBeenCalled() + expect(github.rest.git.deleteRef).not.toHaveBeenCalled() + expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('left as-is')) + }) + + it('deletes a published release whose tag was not cut by the pipeline', async () => { + const qa = release('v1.4.300', 'someone') + const github = createGithub({ + releases: [qa, release('v1.4.215', 'nwparker')], + tags: { 'v1.4.300': { tagger: 'someone@example.com' }, 'v1.4.215': {} } + }) + + await run(github, qa, 'published') + + expect(github.rest.repos.deleteRelease).toHaveBeenCalledWith({ ...repoRef, release_id: qa.id }) + expect(github.rest.git.deleteRef).toHaveBeenCalledWith({ ...repoRef, ref: 'tags/v1.4.300' }) + }) + + it('rejects a bot-tagged tag whose commit is not a release commit', async () => { + const qa = release('v1.4.300', 'someone') + const github = createGithub({ tags: { 'v1.4.300': { subject: 'fix: something' } } }) + + await run(github, qa, 'published') + + expect(github.rest.repos.deleteRelease).toHaveBeenCalled() + }) + + it('accepts scheduled RC release commits', async () => { + const rc = release('v1.4.217-rc.1', 'someone', { prerelease: true }) + const github = createGithub({ + tags: { 'v1.4.217-rc.1': { subject: 'release: v1.4.217-rc.1 [rc-slot:2026-09-28-15]' } } + }) + + await run(github, rc, 'published') + + expect(github.rest.repos.deleteRelease).not.toHaveBeenCalled() + }) + + it('restores Latest to the highest pipeline-cut stable, not only bot-authored ones', async () => { + const github = createGithub({ + releases: [ + release('v1.4.214', 'github-actions[bot]'), + release('v1.4.215', 'nwparker'), + release('v1.4.216-rc.0', 'github-actions[bot]', { prerelease: true }) + ], + tags: { 'v1.4.214': {}, 'v1.4.215': {} } + }) + + await expect(restoreLatestStable(github, repoRef)).resolves.toBe('v1.4.215') + expect(github.rest.repos.updateRelease).toHaveBeenCalledTimes(1) + }) + + it('compares versions numerically when picking Latest', async () => { + const github = createGithub({ + releases: [ + release('v1.4.99', 'github-actions[bot]'), + release('v1.4.100', 'github-actions[bot]') + ] + }) + + await expect(restoreLatestStable(github, repoRef)).resolves.toBe('v1.4.100') + }) +}) diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index a03c85983d8..c1d998717ff 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -388,7 +388,9 @@ async function main() { child.kill('SIGTERM') const exited = await Promise.race([ new Promise((r) => child.on('exit', () => r(true))), - new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS)) + // unref'd: the loser of this race must not hold the event loop open after the + // winner already decided. The timer still bounds the wait. + new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS).unref()) ]) if (!exited) { child.kill('SIGKILL') diff --git a/config/scripts/xterm-dependency-cache.test.mjs b/config/scripts/xterm-dependency-cache.test.mjs new file mode 100644 index 00000000000..4c4f69191ef --- /dev/null +++ b/config/scripts/xterm-dependency-cache.test.mjs @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' +import { parse } from 'yaml' + +const readYaml = (path) => parse(readFileSync(new URL(path, import.meta.url), 'utf8')) +const action = readYaml('../../.github/actions/prepare-xterm-dependencies/action.yml') +const workflow = readYaml('../../.github/workflows/ci-xterm-cache.yml') +const restore = action.runs.steps.find((step) => step.id === 'restore') + +it('separates toolchains and restores dependencies without a stale fallback or build results', () => { + for (const input of [ + 'runner.os', + 'runtime.outputs.image', + 'runner.arch', + 'runtime.outputs.node', + 'xterm-upstream.json', + 'regenerate-xterm-patches.mjs', + 'xterm-patch-text.mjs', + 'prepare-xterm-dependencies/action.yml' + ]) { + expect(restore.with.key).toContain(input) + } + expect(restore.uses).toBe('actions/cache/restore@v5') + expect(restore.with['restore-keys']).toBeUndefined() + expect(restore.with.path.trim().split('\n')).toEqual([ + '${{ runner.temp }}/xterm-patch-build/upstream/.git', + '${{ runner.temp }}/xterm-patch-build/upstream/node_modules' + ]) +}) + +it('publishes only from main after a successful fresh verification, and skips work on hits', () => { + expect(workflow.on.pull_request).toBeUndefined() + expect(workflow.on.push.branches).toEqual(['main']) + expect(workflow.jobs.seed.if).toBe("github.ref == 'refs/heads/main'") + expect(workflow.permissions).toEqual({ contents: 'read' }) + const steps = workflow.jobs.seed.steps + const verify = steps.find((step) => step.name === 'Verify and populate dependencies') + const save = steps.find((step) => step.uses === 'actions/cache/save@v5') + expect(verify.if).toBe("steps.cache.outputs.cache-hit != 'true'") + expect(save.if).toBe(verify.if) + expect(save.with.path).toBe(restore.with.path) + expect(save.with.key).toBe('${{ steps.cache.outputs.cache-key }}') + expect(steps.indexOf(save)).toBeGreaterThan(steps.indexOf(verify)) + expect(verify.run).toContain('regenerate-xterm-patches.mjs --check') + const pr = readYaml('../../.github/workflows/pr.yml').jobs.xterm_patch_sync.steps + expect(workflow.jobs.seed['runs-on']).toBe( + readYaml('../../.github/workflows/pr.yml').jobs.xterm_patch_sync['runs-on'] + ) + expect(pr.some((step) => step.uses === './.github/actions/prepare-xterm-dependencies')).toBe(true) + expect(pr.some((step) => /cache(?:\/save)?@/.test(step.uses ?? ''))).toBe(false) + expect(pr.at(-1).run).toContain('regenerate-xterm-patches.mjs --check') +}) + +it('restores download fallback only on a miss and preserves its established key and paths', () => { + const fallback = action.runs.steps.at(-1) + const save = workflow.jobs.seed.steps.at(-1) + expect(fallback.if).toBe("steps.restore.outputs.cache-hit != 'true'") + expect(fallback.uses).toBe('actions/cache/restore@v5') + expect(fallback.with).toEqual(save.with) + expect(save.if).toBe("steps.cache.outputs.cache-hit != 'true'") +}) + +it('runs the standalone generator without installing unrelated Orca dependencies', () => { + const steps = readYaml('../../.github/workflows/pr.yml').jobs.xterm_patch_sync.steps + expect(steps.some((step) => step.uses === './.github/actions/install-node-dependencies')).toBe( + false + ) + expect(steps.find((step) => step.uses === 'actions/setup-node@v6').with).toEqual({ + 'node-version-file': 'package.json', + 'package-manager-cache': false + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index fe892b20734..030a90632b0 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -6,7 +6,9 @@ "../src/main/agent-state-file-reader.ts", "../src/main/agent-hooks/grok-replay-guard.ts", "../src/main/claude/hook-script.ts", - "../src/main/claude/claude-session-end-hook-capability.ts", + "../src/main/claude/claude-hook-event-versions.ts", + "../src/main/claude/claude-managed-hook-events.ts", + "../src/main/qoder/hook-service.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-post-command.ts", "../src/main/agent-hooks/hook-config-write-path.ts", @@ -18,6 +20,7 @@ "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", "../src/main/agent-hooks/managed-hook-script-refresh.ts", + "../src/main/agent-hooks/managed-hooks-json-events.ts", "../src/main/agent-hooks/posix-hook-command.ts", "../src/main/agent-hooks/runtime-home-hook-command.ts", "../src/main/orca-profiles/profile-storage-paths.ts", @@ -207,6 +210,9 @@ "../src/main/in-flight-run-dedupe.ts", "../src/main/kimi/hook-service.ts", "../src/main/kimi/kimi-hook-config-toml.ts", + "../src/main/dsh/dsh-home-patch.ts", + "../src/main/dsh/hook-service.ts", + "../src/main/dsh/hook-settings.ts", "../src/main/muse/hook-config-json.ts", "../src/main/muse/hook-service.ts", "../src/main/muse/hook-settings.ts", diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index c42b937a467..4f7cc41bcc1 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -6,6 +6,8 @@ "./scripts/vitest-host-ports-setup.ts", "../src/main/**/*", "../src/renderer/src/lib/skill-freshness-display-status.ts", + "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", + "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", "../src/preload/**/*", "../src/shared/**/*", "../src/relay/**/*", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 5c85c3e6980..b81bbc2b0f3 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -1,5 +1,6 @@ import { resolve } from 'node:path' import { defineConfig } from 'vitest/config' +import { UNIT_INCLUDE, UNIT_EXCLUDE } from './scripts/ci-unit-files.mjs' import TimingSequencer from './scripts/ci-unit-sequencer.mjs' const windowsTestWorkerOptions = process.platform === 'win32' ? { maxWorkers: 4 } : {} @@ -33,14 +34,8 @@ export default defineConfig({ resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), resolve('config/scripts/vitest-host-ports-setup.ts') ], - include: [ - 'src/**/*.test.ts', - 'src/**/*.test.tsx', - 'config/scripts/**/*.test.ts', - 'config/scripts/**/*.test.mjs', - 'tests/tools/**/*.test.mjs', - 'tests/e2e/**/*.unit.test.ts' - ], + include: UNIT_INCLUDE, + ...(process.env.ORCA_BALANCE_UNIT_SHARDS === '1' ? { exclude: UNIT_EXCLUDE } : {}), // Why: the full suite runs heavy TS transforms plus real git/http fixtures; // the Vitest 5s defaults are too tight for the slowest integration cases. hookTimeout: 60_000, diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 9053f522c88..9fbda727d28 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 81m + + downloads: 82m @@ -15,7 +15,7 @@ downloads downloads - 81m - 81m + 82m + 82m diff --git a/docs/assets/star-history.png b/docs/assets/star-history.png index f069eb9059c..2de5029eee2 100644 Binary files a/docs/assets/star-history.png and b/docs/assets/star-history.png differ diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index fdd067ef763..17ec41e8294 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -179,6 +179,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   Amp logo Amp   @@ -193,6 +194,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc Charm logo Charm   Cline logo Cline   Codebuff logo Codebuff   + Freebuff logo Freebuff   Command Code logo Command Code   Continue logo Continue   Droid logo Droid   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index 56cfc334fd7..d2757d919b2 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -40,7 +40,7 @@ Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez. -[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -183,6 +183,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i Logo Cursor Cursor   Logo GitHub Copilot GitHub Copilot   Logo Muse Muse   + DeepSeek Harness logo DeepSeek Harness   Logo ZCode ZCode   Logo OpenCode OpenCode   Logo MiMo Code MiMo Code   @@ -199,6 +200,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i Logo Charm Charm   Logo Cline Cline   Logo Codebuff Codebuff   + Logo Freebuff Freebuff   Logo Command Code Command Code   Logo Continue Continue   Logo Droid Droid   @@ -236,7 +238,7 @@ yay -S stably-orca-bin Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone. -- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA) +- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) - **Android :** [Télécharger l'APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index e5eee5781b0..ed37efa595f 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -179,6 +179,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   Amp logo Amp   @@ -193,6 +194,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の Charm logo Charm   Cline logo Cline   Codebuff logo Codebuff   + Freebuff logo Freebuff   Command Code logo Command Code   Continue logo Continue   Droid logo Droid   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 17528c25ee0..25e20764c37 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -179,6 +179,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   MiMo Code logo MiMo Code   @@ -195,6 +196,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Charm logo Charm   Cline logo Cline   Codebuff logo Codebuff   + Freebuff logo Freebuff   Command Code logo Command Code   Continue logo Continue   Droid logo Droid   @@ -231,7 +233,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. -- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA) +- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) - **Android:** [APK 0.0.50 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk) --- diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 568a6d0de0d..baa17f95955 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -36,7 +36,7 @@ Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. -[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK Android 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -179,6 +179,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca. Logotipo do Cursor Cursor   Logotipo do GitHub Copilot GitHub Copilot   Logotipo do Muse Muse   + DeepSeek Harness logo DeepSeek Harness   Logotipo do ZCode ZCode   Logotipo do OpenCode OpenCode   Logotipo do MiMo Code MiMo Code   @@ -195,6 +196,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca. Logotipo do Charm Charm   Logotipo do Cline Cline   Logotipo do Codebuff Codebuff   + Logotipo do Freebuff Freebuff   Logotipo do Command Code Command Code   Logotipo do Continue Continue   Logotipo do Droid Droid   @@ -231,7 +233,7 @@ yay -S stably-orca-bin Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. -- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA) +- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) - **Android:** [Baixar APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 7882935dfa4..54583f4777b 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -179,6 +179,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智 Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + DeepSeek Harness logo DeepSeek Harness   ZCode logo ZCode   OpenCode logo OpenCode   Amp logo Amp   @@ -193,6 +194,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智 Charm logo Charm   Cline logo Cline   Codebuff logo Codebuff   + Freebuff logo Freebuff   Command Code logo Command Code   Continue logo Continue   Droid logo Droid   diff --git a/docs/reference/ci-demand-rollout.md b/docs/reference/ci-demand-rollout.md new file mode 100644 index 00000000000..8219a3401cb --- /dev/null +++ b/docs/reference/ci-demand-rollout.md @@ -0,0 +1,145 @@ +# CI demand rollout + +This implements the September 28 runner-demand analysis. The baseline inventory +covered September 27 04:00–September 28 04:00 UTC: 4,028 workflow runs, with +463 stratified job samples. Estimated occupancy was 1,081 runner-hours, dominated +by PR unit shards (414 hours) and Bun qualification (262 hours). These are +sampled sums of job durations across different runner pools, not billing totals +or a guaranteed forecast of savings. + +## What runs now + +| Work | Ordinary draft update | Ready PR / final checks | Main reference | +| ---------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------- | --------------------------------------- | +| Static analysis and types | Immediately | Immediately | Existing workflows | +| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | +| Packages | After successful static analysis and types | Same | Existing release workflows | +| Bun persistence | Linux x64 for ordinary runtime changes; all six platforms for sensitive inputs | All six platforms when Bun inputs changed | Full nightly qualification at 11:30 UTC | +| Bun glibc/musl qualification | Sensitive inputs only, after persistence succeeds | Both architectures after persistence succeeds | Both architectures | +| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | + +Bun-sensitive inputs include root/toolchain files, configuration, native code, +resources, platform-specific paths, persistence, SQLite, orcad, providers, +daemon, SSH, relay, and child-process code. Dependency discovery failure, a +missing event or an incomplete diff retains full qualification. An unrelated +change still skips Bun through the existing dependency classifier. No native +artifact is shared across platforms or ABIs. The routine draft reduction is an +explicit coverage-placement change; it does not assert identical per-update +coverage. Every non-draft synchronize event and the ready-for-review event +restores full qualification. + +Expensive PR jobs wait for static/type success. This reduces fan-out for failed +or rapidly superseded commits without sleeping on a runner. Successful isolated +PRs pay the extra stage latency. Existing per-PR cancellation remains in place. +Package assertions, native boundaries, SSH/folder coverage, cache warming and +slow-test assertions are retained. + +## Unit selection rollout + +`ci-unit-plan.mjs` discovers the same include/exclude set as Vitest and follows +static imports, re-exports, literal dynamic imports, CommonJS requires and the +renderer aliases. Consumers of indirect filesystem/process inputs remain in the +candidate set, as do script/tool tests. Global configuration changes, deletions, +renames involving removed paths, unknown inputs and graph failures run the full +suite. A shard verifies the plan's source SHA and complete discovery list before +using it. Missing/stale artifacts fall back to full coverage, even if that means +running the suite on fewer shards. + +The initial policy is **shadow**, with all eight shards retained. The first +local inventory matched Vitest exactly (9,950 files at validation); representative +source changes retained roughly 88% of files because of indirect input readers. +That is evidence for conservative coverage, not evidence of the analysis's +hypothetical 50% unit-work reduction. Improvements to indirect dependency +modeling should be demonstrated against full results before expanding selection. + +Every shard uploads `unit-selection.json`, `unit-timings.json` (including module +outcomes), and its assignment. The evidence job combines these into +`unit-selection-review-attempt-N/selection-review.json`, reporting: + +- Whether every discovered file appeared once across a complete reference run. +- Failures outside the candidate set, including failures in otherwise red runs. +- Measured worker time that selection would omit; worker times overlap and are + not runner occupancy or a prediction of wall-clock savings. + +Missing/duplicate shards, stale plans, interrupted runs and unhandled errors do +not count as complete references. Diagnostic upload/report failures do not make +tests pass and do not independently fail successful tests. + +After representative complete shadow runs show no missed failures, set repository +variable `ORCA_UNIT_SELECTION_MODE=selected` to enable selection **only for draft +PRs**. Keep full ready-PR checks and the daily compatibility suite. Inspect at +least a week's evidence across renderer, main, shared, SSH and fixture changes +before promotion, including red runs rather than only successful examples. +Unknown variable values retain shadow mode. Unset the variable or set it to +`shadow` to roll back immediately. Selected runs use one to eight timing-balanced +shards based on retained work. + +Ready-for-review result reuse includes `unit full` in its source/workflow +identity. A green selected draft cannot satisfy the final full check, even if +the repository variable changes between runs. An already successful _full_ +identical-source check can still be reused. + +To inspect downloaded shard artifacts locally: + +```sh +node config/scripts/ci-unit-selection-review.mjs ARTIFACT_DIRECTORY +``` + +## Review automation + +Pullfrog recognizes the existing `Review #N [id]` and +`Review new commits on #N [id]` dispatch names. Explicit dispatchers may provide +`pull_request_number` and `head_sha`. Explicit PR identities share concurrency at the workflow boundary. Legacy review +names use a bounded lookup of the latest 100 dispatches and cancel only lower +run IDs for the same PR; a delayed older scope cannot cancel a newer review. +Unrecognized tasks are never grouped. The scope job alone has Actions write +permission for ordered cancellation. Closed PRs and explicitly stale heads +are skipped. A second head check prevents starting an agent after its queued +head has changed. Unrecognized agent tasks remain independent; lookup failures +also retain an independent task rather than cancelling unrelated work. + +This does not introduce a fixed debounce interval or remove final reviews. +Dispatchers should supply `head_sha` for reliable stale-at-dispatch detection; +legacy names identify a PR but do not prove which head the prompt describes. + +## E2E signal + +The daily reference still executes all shards and keeps original verdicts. Each +shard uploads Playwright JSON and publishes expected, skipped, unexpected, flaky +and startup-error counts with the failing test names/messages. Targeted PR and +manual coverage remain available. This change does not fix the historically +red tests or pretend they pass. + +`config/e2e-failure-tracking.json` can separate an evidenced repeated failure +from new failures in the summary. Each entry must have exact `file`, full +`title`, `project`, a nonempty stable `message` substring, an `@owner`, a linked +repository `issue`, and an ISO `expires` review date. Expired/malformed entries +are ignored and reported; changed error signatures appear as untracked. Entries +never skip a test or change its exit status. The initial list is empty because +the analysis established red workflows but did not establish owners and +reproductions for individual failures. Do not blanket-baseline an entire red run. + +## Capacity measurements and acceptance + +`CI runner demand` runs daily at 04:23 UTC and can be dispatched manually. It +reads the previous 24 complete hours in hourly pages, samples up to six runs per +workflow/outcome stratum, and fetches job pages with bounded concurrency. An +hour exceeding the API's 1,000-result search cap fails visibly. The report and +raw evidence are retained for 30 days. No extra runner pool is provisioned. + +The report measures the full job durations of runs **created** in the window, +not occupancy clipped to the window: earlier runs that overlap it are excluded, +and completed sampled jobs may finish after it. This matches the baseline +cohort method. Workflow IDs keep ref-qualified paths in one sampling stratum. + +The report shows weighted runner-hours and cancelled-run hours per workflow, +runner-minutes per completed PR _run_, and weighted queue/provisioning p95 per +runner label. It counts latest attempts only, excludes incomplete jobs, and +retains zero-job observations. It does not measure other repositories competing +for organization capacity. Compare equivalent traffic windows, not raw totals +alone. The collector needs only `contents: read` and `actions: read`. + +After a week, compare runner-minutes per PR run, cancellation occupancy and +queue p95 in each affected pool. Count newly added planning/reference overhead. +A 25–35% overall reduction remains an experiment target, not an achieved result; +selection, coalescing and matrix reductions overlap and cannot simply be added. diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 3718a65e35e..5a59c75db5e 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1,5 +1,8 @@ # CI efficiency and runner capacity +The [September 28 demand rollout](ci-demand-rollout.md) documents staged checks, +unit-selection evidence, Bun qualification, review cancellation and daily occupancy reports. + ## September 27 follow-up ### Shared E2E CLI output diff --git a/docs/reference/qoder-integration.md b/docs/reference/qoder-integration.md new file mode 100644 index 00000000000..3a3a90121cd --- /dev/null +++ b/docs/reference/qoder-integration.md @@ -0,0 +1,89 @@ +# Qoder CLI integration + +Orca registers `qodercli` as `qoder`: detection, picker/settings, desktop and mobile +identity, prompt launch, permission flags, managed hooks, workspace trust, and +session resume use the existing TUI-agent and hook-status paths. + +## Verified contract + +Verified on macOS with Qoder CLI 1.1.64, including its versioned executable +`qodercli-1.1.64`: + +- `--prompt-interactive` starts an interactive prompt; `--resume ` resumes a + session; `--dangerously-skip-permissions` is the permission bypass flag. +- Hooks use the Claude-shaped nested configuration in `~/.qoder/settings.json`. + Orca registers its own `/hook/qoder` source and preserves user hooks. +- Real SessionStart, UserPromptSubmit, Notification and SessionEnd events are + captured in `src/shared/__fixtures__/qoder-no-account-hooks.jsonl`, including + `source: resume` with the original session ID. +- Trust is `permissions.trustDirectories` in that settings file, using the + canonical workspace path. A workspace `.trusted` marker did not bypass the + trust dialog in this version. Remote trust uses the execution host filesystem. +- Qoder emits its `◇ … | Ready` title while the trust menu owns input. Readiness + therefore requires the live composer text, not merely this title or silence. + Raw PTY fixtures under `src/main/runtime/__fixtures__/qoder-*` cover trust, + unauthenticated prompt handling and the ready composer. +- A hidden Orca dev instance launched Qoder from New Tab in a folder workspace. + Its icon, label, terminal rendering and failed-turn indicator were inspected. + A harmless prompt reached Qoder, which reported its credit usage limit; the + canonical hook store recorded Qoder identity, session metadata and failure. + +## Compatibility and limits + +Windows hooks explicitly select Qoder's documented PowerShell shell, avoiding +an assumption that Qoder uses Git Bash just because it is installed. They reuse +the existing managed `.cmd` payload without adding an encoded PowerShell hop. +The configuration shape and preservation are tested; Windows, Linux and SSH +execution have not been exercised live here. + +Resume requests to remote hosts require `agent-session.qoder-resume.v1`, so an +older host is not sent an agent enum it cannot accept. Remote hook installation +and trust preservation have automated coverage. + +Successful model output, tool execution and live permission dialogs remain +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. + +## Sources + +- [Official hooks documentation](https://docs.qoder.com/cli/hooks) +- [#15291](https://github.com/stablyai/orca/pull/15291): registration and hook proposal +- [#13311](https://github.com/stablyai/orca/pull/13311): icon asset (commit + `b56197025530adb1b82d97a364a8c3d4a02c0d42`) and agent integration +- [#8611](https://github.com/stablyai/orca/pull/8611) and + [#12910](https://github.com/stablyai/orca/pull/12910): contributor implementations +- [#16540](https://github.com/stablyai/orca/issues/16540): Qoder misidentified as Gemini + +Contributor patches informed the integration; they were not applied wholesale. +In particular, the trust marker proposal was replaced using the installed CLI's +observed settings write, and no renderer workaround was added because the live +WebGL terminal rendered correctly. + +## Cross-review of open proposals + +Reviewed the current patches for all six open Qoder PRs on 2026-09-28: + +| PR | Incorporated or confirmed | Deliberate differences | +| --- | --- | --- | +| #7502 (vincent-lxc) | Agent registration, mobile parity, identity before Gemini | Use structural titles and the current public permission flag. | +| #8611 (Eridanus117, building on #7502) | Qoder hook source, notification/permission mapping, session resume | 1.1.64 supports `--prompt-interactive`; preserve startup session identity and ignore compact restarts. | +| #9655 (xingqingzzp-gif) | Cross-checked minimum registration coverage | Placeholder icon and detection-only scope are superseded by the fuller integration; no code copied. | +| #12910 (jyang2004) | Reuse the Claude-compatible installer with an event subset, plus remote installation | Use observed `.qoder` path and `/hook/qoder`; Qwen and the China build remain separate work. | +| #13311 (sorrycc) | Icon, structural title disambiguation, headless detection, display glyph cleanup | No DOM renderer override: 1.1.64 renders correctly in the live WebGL terminal. No unverified executable alias. | +| #15291 (adlternative) | Versioned binary recognition, trust preflight wiring, skills mapping and resume | Trust comes from settings, not `.trusted`; include notifications, permission and failure events with Qoder-specific normalization. | + +The rendered left sidebar was checked in the hidden Electron app using a temporary +folder workspace and real Qoder hooks. The workspace fixture needed a parent path +on its project group to appear in the sidebar. No agent status was injected into +the renderer. A real prompt updated the row text and returned a credit-limit error; +the row and workspace card showed failure with the Qoder icon. A DOM observer +recorded the prompt-row transition before failure. Permission/waiting and successful +completion remain automated event-mapping tests, not live model/tool verification. + +Commit co-authors credit all six proposal authors for the implementation and +registration groundwork, including xingqingzzp-gif’s minimal registration proposal. diff --git a/docs/site/content/docs/agents/supported.mdx b/docs/site/content/docs/agents/supported.mdx index 6532e04fb58..0658ce6fd8c 100644 --- a/docs/site/content/docs/agents/supported.mdx +++ b/docs/site/content/docs/agents/supported.mdx @@ -46,8 +46,10 @@ To restore prompts for one agent only, edit that agent's default arguments or en | Autohand | Auto-setup | [Autohand](https://github.com/autohandai/code-cli) | | Cline | Auto-setup | [Cline](https://docs.cline.bot/cline-cli/overview) | | Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) | +| Freebuff | Auto-setup | [Freebuff](https://freebuff.com/cli) | | Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) | | Muse | macOS/Linux; trusts the workspace at launch | [Meta](https://dev.meta.ai/docs/muse-code) | +| DeepSeek Harness | Launched through its `dsh-tui` profile; hooks, status, questions, resume | [DeepSeek](https://deepseek-harness.github.io/deepseek-harness/) | | ZCode | Deep integration; needs a `zcode` CLI that ships the TUI (see note below) | [Z.ai](https://zcode.z.ai/en/docs) | | Continue | Auto-setup | [Continue](https://docs.continue.dev/guides/cli) | | Cursor CLI | Deep integration | [Cursor](https://cursor.com/cli) | diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx index b27faaf9520..90e9bc6c167 100644 --- a/mobile/app/h/[hostId]/edit.tsx +++ b/mobile/app/h/[hostId]/edit.tsx @@ -19,6 +19,7 @@ import { displayHostEndpoint } from '../../../src/transport/host-endpoint' import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit' import { usePrimeHosts, useRefreshHostClient } from '../../../src/transport/client-context' import type { HostProfile } from '../../../src/transport/types' +import { hostOs } from '../../../src/platform/host-os' export default function EditHostScreen() { const router = useRouter() @@ -179,7 +180,7 @@ export default function EditHostScreen() { ) : ( UIView? = { _ in nil } + let selector = #selector(getter: UIResponder.inputAccessoryView) + let typeEncoding = method_getTypeEncoding(class_getInstanceMethod(UIResponder.self, selector)!) + class_addMethod(created, selector, imp_implementationWithBlock(noAccessory), typeEncoding) + objc_registerClassPair(created) + subclass = created + } + object_setClass(content, subclass) +} + +/// Stops WKWebView reacting to the keyboard itself. It listens for the keyboard's frame and scrolls +/// the document to reveal the focused field, on top of the lift the page already applies from the +/// height the shell sends, so the page moved twice. The shell's own listeners are unaffected. +func ignoreKeyboardNotifications(in webView: WKWebView) { + for name in [ + UIResponder.keyboardWillShowNotification, + UIResponder.keyboardDidShowNotification, + UIResponder.keyboardWillHideNotification, + UIResponder.keyboardDidHideNotification, + UIResponder.keyboardWillChangeFrameNotification, + UIResponder.keyboardDidChangeFrameNotification + ] { + NotificationCenter.default.removeObserver(webView, name: name, object: nil) + } +} diff --git a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift index 0a2122f8183..abf9e7a70ed 100644 --- a/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift +++ b/mobile/modules/orca-mobile-web-shell/ios/MobileWebShellView.swift @@ -221,6 +221,8 @@ final class OrcaMobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate webView.backgroundColor = .clear webView.scrollView.backgroundColor = .clear webView.scrollView.contentInsetAdjustmentBehavior = .never + hideKeyboardAccessoryBar(of: webView) + ignoreKeyboardNotifications(in: webView) webView.translatesAutoresizingMaskIntoConstraints = false addSubview(webView) NSLayoutConstraint.activate([ diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json index 9c5b44c43fe..14863eb6ed5 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json index e95aadfb93f..0fcee77ec0f 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", 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 7aa15d8bfd8..1db6d75eb73 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json +++ b/mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json index c267a0923df..5ffaa3c502a 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-listed.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", diff --git a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json index 8762f7f0646..82e38d52e86 100644 --- a/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json +++ b/mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", 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 b2151e4eaca..d5c046956b7 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", 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 04a34434ff9..a77c715a27b 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json index 5515b3f9dae..8a9790b721a 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json index 014968614bb..a892d7739a2 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json index be5b1907f6f..f696b86ddea 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json index 0ffbd11b622..c121e0d54c7 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json index 7829e14e6ee..2bc7cdb16d7 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json index f67523fa7d3..4262ba6f21e 100644 --- a/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json +++ b/mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", diff --git a/mobile/rpc-foundation/goldens/b1.json b/mobile/rpc-foundation/goldens/b1.json index a9f19ee0947..c980aacea10 100644 --- a/mobile/rpc-foundation/goldens/b1.json +++ b/mobile/rpc-foundation/goldens/b1.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/b2.json b/mobile/rpc-foundation/goldens/b2.json index 24ab5fa888e..a8f0cbfbe91 100644 --- a/mobile/rpc-foundation/goldens/b2.json +++ b/mobile/rpc-foundation/goldens/b2.json @@ -3,7 +3,7 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/b3.json b/mobile/rpc-foundation/goldens/b3.json index 774cdd61758..9fe5cc5de1a 100644 --- a/mobile/rpc-foundation/goldens/b3.json +++ b/mobile/rpc-foundation/goldens/b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json index f5120cf9e81..532bde414af 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-accepted.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json index e238c5efd8b..1080195516c 100644 --- a/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json +++ b/mobile/rpc-foundation/goldens/browser-dialog-dismissed.json @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-keyboard-input.json b/mobile/rpc-foundation/goldens/browser-keyboard-input.json index e26cc94b688..8a1f2b657bc 100644 --- a/mobile/rpc-foundation/goldens/browser-keyboard-input.json +++ b/mobile/rpc-foundation/goldens/browser-keyboard-input.json @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json index f651ff3af02..d82a129a2e6 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json index 8eea8a7133a..c8b892ddb3c 100644 --- a/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json +++ b/mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json index f202b4eba09..1e647f8c0eb 100644 --- a/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json +++ b/mobile/rpc-foundation/goldens/browser-wheel-scrolled.json @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json index bc7dc14eb29..5bb1fc8916b 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 36649c46d1a..1a8293a8c11 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 @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json index 630ef230201..1a055189ffc 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json index b0527824919..1e5eb179479 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 982de088e0a..73634ccd614 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 ac38c0adcb1..9d309faa74b 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 @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json index 7e6a9d65a79..a6138359ec4 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 c82bcfeaade..cedf2b33b69 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 @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 3500963c05c..abc62c495fe 100644 --- a/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json +++ b/mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json index fb7a85c5704..ccea03279bc 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json index bed81dae101..7379c82d88b 100644 --- a/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json +++ b/mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", diff --git a/mobile/rpc-foundation/goldens/components-codex-capability.json b/mobile/rpc-foundation/goldens/components-codex-capability.json index 7c32f429afa..ead4dfee3d1 100644 --- a/mobile/rpc-foundation/goldens/components-codex-capability.json +++ b/mobile/rpc-foundation/goldens/components-codex-capability.json @@ -3,7 +3,7 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-setup-ask.json b/mobile/rpc-foundation/goldens/components-setup-ask.json index dcc93aeaf74..f2afa228f1c 100644 --- a/mobile/rpc-foundation/goldens/components-setup-ask.json +++ b/mobile/rpc-foundation/goldens/components-setup-ask.json @@ -3,7 +3,7 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-target-local.json b/mobile/rpc-foundation/goldens/components-target-local.json index 1b773289364..da6c45d7494 100644 --- a/mobile/rpc-foundation/goldens/components-target-local.json +++ b/mobile/rpc-foundation/goldens/components-target-local.json @@ -3,7 +3,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/components-target-ssh.json b/mobile/rpc-foundation/goldens/components-target-ssh.json index a512c6d2be8..041a0d4110a 100644 --- a/mobile/rpc-foundation/goldens/components-target-ssh.json +++ b/mobile/rpc-foundation/goldens/components-target-ssh.json @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", diff --git a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json index a079f8c8eef..4d04835ac8b 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-compare.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-compare.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 9a53be2d919..67328b16986 100644 --- a/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 be913d2d24f..cd0b56a0dc2 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 @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 127b020e925..4ac2f0398eb 100644 --- a/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-snapshot.json b/mobile/rpc-foundation/goldens/diff-review-snapshot.json index 1700db71f59..bab97a02bdc 100644 --- a/mobile/rpc-foundation/goldens/diff-review-snapshot.json +++ b/mobile/rpc-foundation/goldens/diff-review-snapshot.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json index 88177a9f80e..f3630340c62 100644 --- a/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json +++ b/mobile/rpc-foundation/goldens/diff-review-status-unavailable.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 1885bf1789a..16b90782cd8 100644 --- a/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json +++ b/mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/file-tap-open-refused.json b/mobile/rpc-foundation/goldens/file-tap-open-refused.json index 2eb28ae1923..2c53dc076b7 100644 --- a/mobile/rpc-foundation/goldens/file-tap-open-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-open-refused.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", 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 ec4e54ddefd..5fc43a8f830 100644 --- a/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json +++ b/mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", 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 418a13757fb..53215f54c4c 100644 --- a/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json +++ b/mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json index bf106b5bd2e..8adb1d19d57 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-miss.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json index c643cc6dd2e..0b876017b9f 100644 --- a/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json +++ b/mobile/rpc-foundation/goldens/file-tap-resolve-refused.json @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", diff --git a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json index 93b9a163b48..8f9c1b500f2 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json +++ b/mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/files-explorer-readdir.json b/mobile/rpc-foundation/goldens/files-explorer-readdir.json index d9bead25817..2510dcb8da5 100644 --- a/mobile/rpc-foundation/goldens/files-explorer-readdir.json +++ b/mobile/rpc-foundation/goldens/files-explorer-readdir.json @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", diff --git a/mobile/rpc-foundation/goldens/files-ownership-local.json b/mobile/rpc-foundation/goldens/files-ownership-local.json index 4ad0e75726c..3c8785507d4 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-local.json +++ b/mobile/rpc-foundation/goldens/files-ownership-local.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-ownership-ssh.json b/mobile/rpc-foundation/goldens/files-ownership-ssh.json index 769d0015e16..869b69ea732 100644 --- a/mobile/rpc-foundation/goldens/files-ownership-ssh.json +++ b/mobile/rpc-foundation/goldens/files-ownership-ssh.json @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json index be80480f84c..f7ad51f0bf4 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-direct.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 c1d73a55193..2d6eeb2be0e 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json @@ -3,7 +3,7 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json index 407fc00c698..dfdb817d937 100644 --- a/mobile/rpc-foundation/goldens/files-preview-artifact-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-artifact-image.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json index 5f921eb5d70..acedc84c06b 100644 --- a/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json +++ b/mobile/rpc-foundation/goldens/files-preview-grant-refresh.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 ac5808b9ab3..639277d8b56 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json index 3e5ab9f6b91..da550e85887 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-image.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-image.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 82280c3db62..51a3d994d72 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json @@ -3,7 +3,7 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-preview-worktree.json b/mobile/rpc-foundation/goldens/files-preview-worktree.json index f31799a7d5f..5589a7e33ca 100644 --- a/mobile/rpc-foundation/goldens/files-preview-worktree.json +++ b/mobile/rpc-foundation/goldens/files-preview-worktree.json @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-save-blind.json b/mobile/rpc-foundation/goldens/files-save-blind.json index 02fbffa55d2..a4aada23016 100644 --- a/mobile/rpc-foundation/goldens/files-save-blind.json +++ b/mobile/rpc-foundation/goldens/files-save-blind.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-save-verified.json b/mobile/rpc-foundation/goldens/files-save-verified.json index 44945dbac93..f457d594a06 100644 --- a/mobile/rpc-foundation/goldens/files-save-verified.json +++ b/mobile/rpc-foundation/goldens/files-save-verified.json @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json index 13feb7dbb33..5e112a24495 100644 --- a/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json +++ b/mobile/rpc-foundation/goldens/files-tab-doc-shapes.json @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", diff --git a/mobile/rpc-foundation/goldens/home-host-accounts.json b/mobile/rpc-foundation/goldens/home-host-accounts.json index aa1c57ec8bc..138ee27c578 100644 --- a/mobile/rpc-foundation/goldens/home-host-accounts.json +++ b/mobile/rpc-foundation/goldens/home-host-accounts.json @@ -3,7 +3,7 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", diff --git a/mobile/rpc-foundation/goldens/home-host-stats.json b/mobile/rpc-foundation/goldens/home-host-stats.json index a962ed098bb..1a582a21884 100644 --- a/mobile/rpc-foundation/goldens/home-host-stats.json +++ b/mobile/rpc-foundation/goldens/home-host-stats.json @@ -3,7 +3,7 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", diff --git a/mobile/rpc-foundation/goldens/host-view-settings-sync.json b/mobile/rpc-foundation/goldens/host-view-settings-sync.json index 688cdd30ec8..42c1e36b480 100644 --- a/mobile/rpc-foundation/goldens/host-view-settings-sync.json +++ b/mobile/rpc-foundation/goldens/host-view-settings-sync.json @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", 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 264599ad960..1783e6c491b 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 @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json index dbd01c957f6..7f7cad606f5 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json +++ b/mobile/rpc-foundation/goldens/host-worktree-delete-refused.json @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", diff --git a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json index 512b0dcf8a6..e10f751b1f3 100644 --- a/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json +++ b/mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", diff --git a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json index 1be50195f5a..a1e43b0e072 100644 --- a/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", 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 46cabf4fd2d..a80a49405f3 100644 --- a/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/inventory-lifecycle.json b/mobile/rpc-foundation/goldens/inventory-lifecycle.json index e5f335032ed..86cc5a797be 100644 --- a/mobile/rpc-foundation/goldens/inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/inventory-repeat-query.json b/mobile/rpc-foundation/goldens/inventory-repeat-query.json index ff4706aaa59..e13e271c53f 100644 --- a/mobile/rpc-foundation/goldens/inventory-repeat-query.json +++ b/mobile/rpc-foundation/goldens/inventory-repeat-query.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", diff --git a/mobile/rpc-foundation/goldens/lifecycle-b3.json b/mobile/rpc-foundation/goldens/lifecycle-b3.json index f3dcf8667e7..70e4e2bbcba 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-b3.json +++ b/mobile/rpc-foundation/goldens/lifecycle-b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index 5543e333169..70618945c1c 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", 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 24b178755e8..1b5f354c5f1 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 873bbd064cf..32b971e6c96 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 9f2383fab07..bb177b8f14a 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/linear-select-workspace.json b/mobile/rpc-foundation/goldens/linear-select-workspace.json index 013a1aba847..6d66f10d6e7 100644 --- a/mobile/rpc-foundation/goldens/linear-select-workspace.json +++ b/mobile/rpc-foundation/goldens/linear-select-workspace.json @@ -3,7 +3,7 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", diff --git a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json index b942e40cc77..0eb76ef03a3 100644 --- a/mobile/rpc-foundation/goldens/live-worktree-name-stream.json +++ b/mobile/rpc-foundation/goldens/live-worktree-name-stream.json @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 aa1d4e0d703..0dd95e326d0 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 @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", 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 07a4399b62d..2aa03df5a0b 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 @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", 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 15e9c2f4453..ff9b9718573 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 @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", 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 d0cf0618a2f..f1c1ee9933e 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 @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", 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 8f2dacb5e51..9abbabeb545 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 @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", 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 22b54335055..7c7e63ff8e6 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 @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", 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 78fe0babedc..25f821fee69 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 @@ -3,7 +3,7 @@ "family": "aiVault.history-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3", 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 dcf67b041a0..6873290ccf1 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 @@ -3,7 +3,7 @@ "family": "aiVault.history", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "ec16931c5431d3dbb05729d4670c381a434bff71648d334b7b5224db188fdccb", 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 b9b34271a28..b35d25482ee 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 @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", 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 2d3e53120f3..f1980140963 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 @@ -3,7 +3,7 @@ "family": "aiVault.resume-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", 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 85f516eed2e..41b607ed3f6 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 @@ -3,7 +3,7 @@ "family": "aiVault.resume-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb", 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 adaa8ba57df..675841953d7 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 @@ -3,7 +3,7 @@ "family": "browser.dialog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", 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 52659492140..82d458cc64b 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 @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", 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 f1148cbf996..d359fa7f825 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 @@ -3,7 +3,7 @@ "family": "browser.keyboard", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", 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 8a3f02c2941..0a86c5777cf 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 @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", 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 c1e61603f8d..d8ef9402bd4 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 @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", 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 5ad3210ab55..e156567d696 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 @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", 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 662807b2ac9..480078b2895 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 @@ -3,7 +3,7 @@ "family": "browser.pointer-click", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", 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 93f35f5aedd..ac2d3276d30 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 @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", 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 f45f476d7f8..4f6c047f4dc 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 @@ -3,7 +3,7 @@ "family": "browser.wheel", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658", 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 82cf496d791..fe3c3c6a406 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 @@ -3,7 +3,7 @@ "family": "clipboard.image-attachment", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 bed81f1b044..5f569d3594b 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 @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 6db467a2e16..a6a213fa2e9 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 @@ -3,7 +3,7 @@ "family": "clipboard.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 16a42b7feda..1584210054e 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 @@ -3,7 +3,7 @@ "family": "components.codex-reset-capability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", 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 a3ca75ddcbd..467f627c04a 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 @@ -3,7 +3,7 @@ "family": "components.codex-reset-credit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "76b53dea504a688493843e23e8e7d052fc196f559bb1e2579d0fd38bf3156d0e", 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 0efa78157b0..ad5a116fd68 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 @@ -3,7 +3,7 @@ "family": "components.execution-target-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", 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 eb703788188..ee80824dc83 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 @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", 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 fc0e72ed9f6..ee6a81d855a 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 @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", 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 7c2f6732183..5b54a462fe2 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 @@ -3,7 +3,7 @@ "family": "components.execution-target", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", 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 e5edc7549e1..ffd3f00d855 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 @@ -3,7 +3,7 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", 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 cf1f9c36848..ca1e153e8d7 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 @@ -3,7 +3,7 @@ "family": "components.setup-script", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5cfbce3c7d97d908fbd447646d611e41a8aa1f61f684b9b710c4b67d6ff023a7", 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 da8cdb25dd8..f1f02ac024c 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 @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", 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 09f62254bdd..012059fdf03 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 @@ -3,7 +3,7 @@ "family": "files.explorer-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "7a42a348f4407b94cf75ac77a4b6b783b7d28103b1ae1d111d2708dab0dd4a0c", 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 1b0da8aaa97..9958e76d69a 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 @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 1d3ef135ba4..4797219f674 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 @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 64824c81998..04ca092f761 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 @@ -3,7 +3,7 @@ "family": "files.mutation-ownership", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 db7c4973540..b44c387ac7c 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 @@ -3,7 +3,7 @@ "family": "files.preview-artifact-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 abff8d9e939..2a028952120 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 @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 2c898067555..86ecd0352f7 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 @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 e0e18f5693e..edb175af835 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 @@ -3,7 +3,7 @@ "family": "files.preview-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 d662d1f6869..701b00772ee 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 @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 e6d1958c6eb..20d302012cc 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 @@ -3,7 +3,7 @@ "family": "files.preview-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 dee03efcc73..aae0ab561aa 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 @@ -3,7 +3,7 @@ "family": "files.preview-worktree-image", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 719d8ad0112..c5e9f640755 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 @@ -3,7 +3,7 @@ "family": "files.preview-worktree-text", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 bd7154477cc..b9704e0e790 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 @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 0118f455308..0ef791fe93e 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 @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 756769b6ec6..51c711d4233 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 @@ -3,7 +3,7 @@ "family": "files.tab-doc", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b93fc8b6f1ece041acaa8cc5699852085e67d7e2f39db13145e483f854ea3309", 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 4c6f90e5c33..1cba34985bc 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 @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", 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 565a7e26d86..86d83ddee9e 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 @@ -3,7 +3,7 @@ "family": "files.terminal-path-tap", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e20a76ecd5e820dc4797fb25307810af68b5ced996dba2b9599464a21b5cbe1b", 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 fe1f6c6473c..4772cb6adb5 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 @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 3d076db178b..33768d3d81c 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 @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 92d208b1e8c..a5e80297181 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 @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 bbfe0538625..837fc221dc6 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 @@ -3,7 +3,7 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", 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 4f8d82d6568..21f412d5aa6 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 @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", 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 06ec68cbcdd..505b519e89b 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 @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", 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 12378643a8b..fedb8e55d36 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 @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", 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 69666ce3f9a..411a2b2d435 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 @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", 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 59444fa8ce9..446ad90a026 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 @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 0506e48cc6a..a92368dd725 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 @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", 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 0a1427fd632..ebbc8649086 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 @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", 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 88af61c5928..c4d29c3fb19 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 @@ -3,7 +3,7 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 a25b1fb2aa9..9c8ba886c2d 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 @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 b25915e3524..df9240be29f 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 @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 3288a86a2bd..08746cbd4f9 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 @@ -3,10 +3,10 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "0c8f09683408a919dd3ac2b7cd12197d8745882627134cde95ecee209575b027", "platform": "darwin", "scenarioVersion": 1, 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 3543fae6e58..7a9473ab0d6 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 @@ -3,10 +3,10 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "4f2b61df5e59d2efe81d467214a78132654035cbcb4d929385410b53f735c9fe", "platform": "darwin", "scenarioVersion": 1, 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 de7edadade1..ac09e3a8344 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 @@ -3,10 +3,10 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "04b2052cce6f24d9e208c992f204355639409ddc9793b3044b394c0e38d3c284", "platform": "darwin", "scenarioVersion": 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 4a92b447cb4..569f79e8ba5 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 @@ -3,10 +3,10 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "8f849437158296753a84a75dfdbaf69852bcdb6cdc6e8205496961ec16e4be21", "platform": "darwin", "scenarioVersion": 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 3460c8a5105..c470f12d385 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 @@ -3,10 +3,10 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "77eba7deff3f15e64795cdcbefd009abfcf2379e7293eefb1f154ca1e99f4d5d", "platform": "darwin", "scenarioVersion": 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 260dceb18a6..1a47943d987 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 @@ -3,10 +3,10 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "2235d05e0d2c6f1a9518cfdd76870e303cccd35289d6a44334147b6a5b6b675e", "platform": "darwin", "scenarioVersion": 1, 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 bb17f931df9..d8415c0685c 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 @@ -3,10 +3,10 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "2cea87c339b35daf62c963092c01c6379db43f2ee4ca5cb2b5f817975b0caf65", "platform": "darwin", "scenarioVersion": 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 d11c5ec9819..45bf3d7fef0 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 @@ -3,10 +3,10 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "78de11de556c91782590725819b22821732a12d3769f493d165c80bc7fcc1f53", "platform": "darwin", "scenarioVersion": 1, 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 38371a12f80..9bf6a49bb38 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 @@ -3,10 +3,10 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "ac7e91b4d35021eca63af8ce01f9a2c7959109e4cb824009881437cb94dbfe82", "platform": "darwin", "scenarioVersion": 1, 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 2c84b6d6521..b4fffd04b69 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 @@ -3,10 +3,10 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "a85682e5009d634bf468b8dbe4f35a957988754ed9c2b07d57ad475c1590d1f6", "platform": "darwin", "scenarioVersion": 1, 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 0ff119ac5ae..d2b57069195 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 @@ -3,10 +3,10 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "30f321fcbfaf09505bc6e18e64c09ca49c0dcdb7c5e12c4e27c2429b3e1066ca", "platform": "darwin", "scenarioVersion": 1, 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 6b29ab74c95..8663551cd07 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 @@ -3,10 +3,10 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "507743a5925a37be32156b3d8df83ddb8e08c262d2c2f44bbd117dee4672bbc5", "platform": "darwin", "scenarioVersion": 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 f563b6f083e..8b47ed74b92 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 @@ -3,10 +3,10 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "6efca320cf31a1de988110004a9fb7b67fdad279a789e046ad6b5141b66e5bf1", "platform": "darwin", "scenarioVersion": 1, 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 1a24e6e9ac3..6f528205be1 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 @@ -3,10 +3,10 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "c77d7c7a05ecb9b27180ef28ca63a28e1c1ca42db2bb54a468699da77281ae35", "platform": "darwin", "scenarioVersion": 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 deab639fc27..a633598fa80 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 @@ -3,10 +3,10 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "3b3d3032b992fc7461b13de8a42512affa12fb018ad583898179a9934c13b414", "platform": "darwin", "scenarioVersion": 1, 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 cc1ec872e44..a54073d825e 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 @@ -3,10 +3,10 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "4ebe865f874b0b4a813860ec7356b8dc214ea02f0a9036cb003efe863d89b83e", "platform": "darwin", "scenarioVersion": 1, 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 d76a4b9903e..c160111e9e8 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 @@ -3,10 +3,10 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "bc3350efc030f824fc21046aea8c6dc9a46993b6c75613c66874fde59af9171a", "platform": "darwin", "scenarioVersion": 1, 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 e891a7f1635..ce776c7f340 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 @@ -3,10 +3,10 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "9171e982babc4e56852fe35bafa7a9f3aeda2be5bd4648f29aaa04ca7119d5d2", "platform": "darwin", "scenarioVersion": 1, 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 4a079084d75..75497449e06 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 @@ -3,10 +3,10 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "fe91d5518501a078dff3010e74c4b9d70122f88a629e384336cd1b6a84de36a8", "platform": "darwin", "scenarioVersion": 1, 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 cb3327d2559..b756f0e26eb 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 @@ -3,7 +3,7 @@ "family": "home.host-accounts", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c632fdbc4b730777ecb09f08bec14cca0586042b01ed99d40d0228806c7def4a", 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 6ad4f54f7ba..860b9a4214b 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 @@ -3,7 +3,7 @@ "family": "home.host-stats", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", 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 4f596f6bb92..8509139ab93 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 @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 dd68128dd9c..a35f134c963 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 @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 dac204431a3..efceb3aca89 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 @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 572dee4091a..4341e370fce 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 @@ -3,7 +3,7 @@ "family": "host-worktree-refresh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 ee5fa65362a..888840f25b2 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 @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", 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 faa24c216cc..4c6e4995ba1 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 @@ -3,7 +3,7 @@ "family": "host.view-settings", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a9e0780298a1443664e7ae02056168aa34d67556c9c056d51a82c7b4a73ad35b", 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 6a2594ab230..cd25bda2a97 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 @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", 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 fe0a8078c84..aea83826f58 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 @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", 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 f0a4c4e8b57..6b9dab5fdd9 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 @@ -3,7 +3,7 @@ "family": "host.worktree-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "92c29bd78ca0c0d5917e9386fc447bb9a1698b1d1ffaba0db7546eaac60da639", 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 7ec14055a88..36a506da696 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 93c7c7c49b2..d1753f4425b 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 2cc9887fac1..0c5e2396e0e 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 eda79947dac..2df5db78fe1 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 caaebe6c8ff..56a0f6948ae 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 f68391c23fe..b32c45655df 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 0249f2d2bca..4a7a2504aba 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 997f17af2f3..2cf03eb5cbd 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 3b26eccbe7d..053bc25523b 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 9180497a1fe..b4a5e9c257f 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 f9448039bb3..7a84c2dddc0 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 6befe2c022d..01e9ec501a9 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 a315886a467..09368580565 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 a2d5b32a157..eb0269a9e9d 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 b890426b031..df36974ab3c 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 919399085d8..3619df0912a 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 @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 889e8c64aba..e694b94eebd 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 @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", 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 9a2ed61e04d..f69f771fd9c 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 @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", 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 e951d309e4b..09b3cbae874 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", 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 a58aec5f6fa..5aca6361988 100644 --- a/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json +++ b/mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json @@ -3,7 +3,7 @@ "family": "legacy-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca", 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 b4dbb996c4b..3d3020deaeb 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 @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", 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 d2d688a1dc2..6a39e49786a 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 @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", 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 664d3af2f81..b31443e0913 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 @@ -3,7 +3,7 @@ "family": "linear.select-workspace-picker", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b65996c152b632d553a31e07e31ea5c76f998eada42cf0966923d730da21908f", 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 4f440637e62..74ffddc8b2b 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 @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 2beebf5d70d..6d72e3913f2 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 @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 d5a8eacd152..4017eb86085 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 @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 0c846244bf5..783985104e7 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 @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 eed79fe2d99..06af0bb3874 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 @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 7ef01efbd24..df5294fc033 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 @@ -3,7 +3,7 @@ "family": "live-worktree-name", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8e41c8624b9b6185e447cee3590a851ab88b6b1ee1d632af90e3a771a90310be", 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 3d30410d2eb..fe736f7396d 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 @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", 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 536aebf9157..c2cbe4c9b80 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 @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", 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 dea14a2258a..7f0da328ff6 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 @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", 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 b3f3359de10..129834bd4c1 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 @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", 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 fa1a7133da9..d5d66e9516b 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 @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-manifest", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", 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 89a9eb7cc70..f802878a60a 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 @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 9a0623879b2..01042c9b755 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 @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 2281ed8cdc1..8aad2e65a2a 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 @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 6d82c5e0067..ea997ee1280 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 @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", 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 391c5869730..72033dfd789 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 @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", 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 f6ba2e1e61e..e3f8dbb1528 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 @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", 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 8e035da2d5c..255c3d46378 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 @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", 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 c38b6e055ef..9561cf1c0fd 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 @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", 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 563bbd66db0..bc1ba1fca74 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 @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", 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 ea8ab3ed3a6..580ba532dc6 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 @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", 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 33eaf53c112..9c021bd6b64 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 @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", 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 ca39fe2b521..a25a4546f4e 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 @@ -3,7 +3,7 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", 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 3bfb896987f..3cde5272046 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 @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", 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 b131000951d..98b09225b58 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 @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", 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 58f2f591954..878a7b2dc42 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 @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", 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 c86c8280a3d..a66ed6e69f5 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 @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", 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 01ae3a71b64..b5920461495 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 @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", 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 120497b10ea..38a4ee6d5f0 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 @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", 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 1b2b4700a64..46d7585d11f 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 @@ -3,7 +3,7 @@ "family": "project-explicit-false", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", 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 8daf56fdc79..f1b5b508ccf 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 @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", 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 e863ae74e92..7698befb4c5 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 @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", 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 4c8bb8a391f..edc674f77ef 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 @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", 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 91e31c95a60..66c4a247716 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 @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", 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 a5a04a2ceb8..7bf353e8371 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 @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", 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 12d460eddd7..a9ac9f36c10 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 @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", 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 4e8c365f4d2..c1b44082403 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 @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", 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 e5e3da4a404..52aca46c39a 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 @@ -3,7 +3,7 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 443f2031daf..ca2bf0d57ab 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 @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 6692b3b1b38..b306aff0691 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 @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 a00adf5fc6c..6bd3875e6c6 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 @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 b599aac7e0d..8b995a39f1d 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 @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 0948078f0db..89ea3ff7faf 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 @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", 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 872ebecbccf..66a44807117 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 @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", 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 b43f2848938..50dcddadb55 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 @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", 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 d3e0d3187e6..039d20a5662 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 @@ -3,10 +3,10 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "c583058382c949e977b7a1287895ed9ccb8cb408b684aef186b629976ff1da4d", "platform": "darwin", "scenarioVersion": 1, 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 1848093ca7d..25234737006 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 @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 7bd703be4ed..3e636b0a1db 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 @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 7991b3dd8d0..377a84d8ec0 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 @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 c6cc15c8f28..ea586be64af 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 @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 27916f35478..1058b2aef3a 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 @@ -3,7 +3,7 @@ "family": "session.diff-review", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 a010433b0ce..b110bd884a1 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 @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 251652a6643..91909e865ff 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 @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 fd222140f6a..fb1c7e64b19 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 @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", 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 e124dee6a65..7bcea9f41e3 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 @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", 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 7cc64a90cac..301135042b2 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 @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", 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 ff984c2a9f4..1b63830b2de 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 @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", 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 f0b00298eb5..1f825b99b80 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 @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 146c950d61b..90bd2675ef3 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 @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 239b09a0b81..a6ab1484dd1 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 @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 a7b4be9d636..be04167eb32 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 @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 1fa5764eeaf..35356ae5376 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 @@ -3,10 +3,10 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "af78542ad2c449b629f8705b940ec92fd16f879a9bcc81ff6ae3a192f804fa2c", "platform": "darwin", "scenarioVersion": 1, 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 239419edd0c..9190412e168 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 @@ -3,10 +3,10 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "1e7cad00f4dfcda65a3830b0b2468020816b940611af1b68600de33cd8c1d7c2", "platform": "darwin", "scenarioVersion": 1, 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 8fd282ff218..4be13a32f11 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 @@ -3,10 +3,10 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "d456ec18056eb9663e99d4991784bd802422256d81ddd7509720814da06915f4", "platform": "darwin", "scenarioVersion": 1, 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 6389f97aa03..8e9287ac1da 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 @@ -3,10 +3,10 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "039f26cc90d2239028d6ad1d9ecae9cc976d48f386f2d29fbfa425afc702657d", "platform": "darwin", "scenarioVersion": 1, 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 2aba56fdb43..c08ed1bbc21 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 @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", 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 0e576fe4206..d10ae826214 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 @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", 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 75c2996977f..a77e3f98f5a 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 @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", 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 00b9db95655..79217dcc90d 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 @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", 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 141f1d0997c..02ec381a268 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 @@ -3,258 +3,56 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "c9030f183de2c5e86c2ccdfd9d4527d6f2ed71855501ff061620cef2681c6b82", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", + "scenarioSha256": "0d06d27000a8f6ad66480a16c7b84e8464f4b6e1d775326aeae005926e134cb4", "platform": "darwin", "scenarioVersion": 1, "projectionVersion": 2, "goldenFormatVersion": 5, "values": { - "01fc75db313f": { - "launched": { - "kind": "not-started", - "message": "The host sent a reply this app could not read (repo.list)" - } - }, - "02188f091419": { - "name": "settings.get#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "settings": { - "defaultTuiAgent": "codex", - "disabledTuiAgents": [] - } - } - } - } - }, - "12e70439f294": { - "name": "repo.list#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "repo.list" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "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 - } - } - }, - "1321b4c8c0b8": { - "name": "repo.list#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "repo.list" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true - } - } - }, - "1963ee2edae9": { - "launched": { - "kind": "not-started", - "message": "Unknown method" - } - }, - "1b23dab83fcf": { - "name": "repo.list#1", - "ordinal": 1, - "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-1", - "ok": true, - "result": { - "error": "refused" - } - } - } - }, - "1c2564f1daca": { - "name": "settings.get#1", + "280ce0d6f832": { + "name": "terminal.send#1", "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"term-1\",\"text\":\"Fix the failing checks\",\"enter\":true}}" }, - "28339abecf78": { - "name": "agent.launchReplay#1", - "ordinal": 8, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"agent.launchReplay\",\"params\":{\"agent\":\"codex\",\"operationId\":\"1767225600000-00000000000040008000000000000001\",\"target\":{\"kind\":\"existing\",\"worktree\":\"id:repo-9::/w\"},\"prompt\":{\"text\":\"Fix the failing checks\",\"delivery\":\"submit\"},\"launchSource\":\"task_page\"}}" - }, - "331b3596787f": { - "launched": { - "kind": "not-started", - "message": "outer refused" + "30ec57518c05": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Failed to create terminal", + "isRpcDeliveryUnknown": false } }, - "35f85fe3b71c": { - "name": "repo.list#1", + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "3f739faafdd4": { + "name": "session.tabs.createTerminal#1", "ordinal": 1, "args": [ { "name": "method", - "value": "repo.list" + "value": "session.tabs.createTerminal" }, { "name": "params", "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "3e6a0417d2ee": { - "name": "repo.list#1", - "ordinal": 1, - "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-1", - "ok": true, - "result": { - "repos": [ - { - "id": "repo-9" - } - ] - } - } - } - }, - "48c68906a98a": { - "name": "repo.list#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "repo.list" - }, - { - "name": "params", - "value": { - "$rpc": "absent" + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" } }, { @@ -277,79 +75,31 @@ } } }, - "51d4cb56be85": { - "name": "settings.get#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 + "478ad4afe83e": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "The host sent a reply this app could not read (session.tabs.createTerminal)", + "isRpcDeliveryUnknown": false } }, - "624ec4e5082c": { - "name": "repo.list#1", + "5093e98ba369": { + "name": "session.tabs.createTerminal#1", "ordinal": 1, "args": [ { "name": "method", - "value": "repo.list" + "value": "session.tabs.createTerminal" }, { "name": "params", "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-1", - "ok": false - } - } - }, - "747c556da67a": { - "name": "repo.list#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "repo.list" - }, - { - "name": "params", - "value": { - "$rpc": "absent" + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" } }, { @@ -365,22 +115,33 @@ "settledAt": 0, "value": { "id": "frame-1", - "ok": true + "ok": true, + "result": { + "tab": { + "id": "tab-1", + "terminal": "term-1", + "title": "Agent", + "type": "terminal" + } + } } } }, - "8a72d3d14d44": { - "name": "repo.list#1", + "5422be2c33b6": { + "name": "session.tabs.createTerminal#1", "ordinal": 1, "args": [ { "name": "method", - "value": "repo.list" + "value": "session.tabs.createTerminal" }, { "name": "params", "value": { - "$rpc": "absent" + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" } }, { @@ -404,122 +165,356 @@ } } }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "9c16efc31903": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex"] - } - } - }, - "a4273b38df83": { - "launched": "unlaunched" - }, - "b13ddf18a62e": { - "launched": { - "kind": "not-started", - "message": "Could not load the available agents." - } - }, - "ba3fac1eb199": { - "name": "agent.launchReplay#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "agent.launchReplay" - }, - { - "name": "params", - "value": { - "agent": "codex", - "launchSource": "task_page", - "operationId": "1767225600000-00000000000040008000000000000001", - "prompt": { - "delivery": "submit", - "text": "Fix the failing checks" - }, - "target": { - "kind": "existing", - "worktree": "id:repo-9::/w" - } - } - }, - { - "name": "options", - "value": { - "timeoutMs": 90000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "outcome": { - "handle": "term-1", - "kind": "terminal" - }, - "prompt": { - "delivery": "submit", - "outcome": "handed-to-terminal" - }, - "receipt": { - "detail": "Started a terminal agent, the default for new agent tabs in your settings.", - "mode": "terminal", - "preferred": "terminal", - "reason": "user_default" - }, - "worktreeId": "repo-9::/w" - } - } - } - }, - "d68475063b62": { - "name": "repo.list#1", + "6bf6a76b4bf4": { + "name": "session.tabs.createTerminal#1", "ordinal": 1, "args": [ { "name": "method", - "value": "repo.list" + "value": "session.tabs.createTerminal" }, { "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", "value": { "$rpc": "absent" } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "72295d1a7229": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "session.tabs.createTerminal" + }, + { + "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "error": "refused" + } + } + } + }, + "76bc1a917546": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "session.tabs.createTerminal" + }, + { + "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "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 + } + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "94d38f1838f6": { + "name": "session.tabs.createTerminal#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:repo-9::/w\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}" + }, + "a4273b38df83": { + "launched": "unlaunched" + }, + "a46a4a08b27d": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "session.tabs.createTerminal" + }, + { + "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "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 + } + }, + "ae5875338dfe": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "session.tabs.createTerminal" + }, + { + "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "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 + } + } + }, + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } + }, + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + }, + "dea9ae4cf53d": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "session.tabs.createTerminal" + }, + { + "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-1", + "ok": false + } + } + }, + "e3995d24a794": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "session.tabs.createTerminal" + }, + { + "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true + } + } + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "f9b284ac2424": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "session.tabs.createTerminal" + }, + { + "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "fbddf84c2257": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "session.tabs.createTerminal" + }, + { + "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" + } }, { "name": "options", @@ -544,74 +539,23 @@ } } }, - "dae756300589": { - "name": "repo.list#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "repo.list" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } + "fe1fe746e77a": { + "launched": "sent" }, - "e0eef7c9fafe": { - "name": "repo.list#1", + "fe53d3bce307": { + "name": "terminal.send#1", "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "e35d7003f64c": { - "launched": { - "kind": "not-started", - "message": "transport failure" - } - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, - "eb8d47f90956": { - "launched": { - "kind": "sent" - } - }, - "f1d579dd459c": { - "name": "repo.list#1", - "ordinal": 1, "args": [ { "name": "method", - "value": "repo.list" + "value": "terminal.send" }, { "name": "params", "value": { - "$rpc": "absent" + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" } }, { @@ -626,29 +570,25 @@ "startedAt": 0, "settledAt": 0, "value": { - "error": { - "code": "refused", - "message": "" - }, - "id": "frame-1", - "ok": false + "id": "frame-2", + "ok": true, + "result": { + "send": { + "accepted": true + } + } } } - }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" } }, "recording": { - "scenario": "matrix-session.pr-triage-repo.list-1", + "scenario": "matrix-session.pr-triage-session.tabs.createterminal-1", "checkpoints": [ { "id": "pr-triage-launch.prelude:pending", "observation": { - "sender": ["35f85fe3b71c", "51d4cb56be85"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["f9b284ac2424"], + "payloads": ["94d38f1838f6"], "settlements": { "launch": "9270aeb7d9c6" }, @@ -659,132 +599,132 @@ { "id": "pr-triage-launch.normal:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["5093e98ba369", "fe53d3bce307"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { "launch": "eb79a9b3682a" }, - "state": "eb8d47f90956", + "state": "fe1fe746e77a", "effects": [] } }, { "id": "pr-triage-launch.result-absent:launched", "observation": { - "sender": ["747c556da67a", "02188f091419"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["e3995d24a794"], + "payloads": ["94d38f1838f6"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "478ad4afe83e" }, - "state": "01fc75db313f", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.result-null:launched", "observation": { - "sender": ["48c68906a98a", "02188f091419"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["3f739faafdd4"], + "payloads": ["94d38f1838f6"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "478ad4afe83e" }, - "state": "01fc75db313f", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.inner-ok-missing:launched", "observation": { - "sender": ["1b23dab83fcf", "02188f091419"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["72295d1a7229"], + "payloads": ["94d38f1838f6"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "478ad4afe83e" }, - "state": "01fc75db313f", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.inner-false-string-error:launched", "observation": { - "sender": ["8a72d3d14d44", "02188f091419"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["5422be2c33b6"], + "payloads": ["94d38f1838f6"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "478ad4afe83e" }, - "state": "01fc75db313f", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.inner-false-object-error:launched", "observation": { - "sender": ["d68475063b62", "02188f091419"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["fbddf84c2257"], + "payloads": ["94d38f1838f6"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "478ad4afe83e" }, - "state": "01fc75db313f", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.outer-refused:launched", "observation": { - "sender": ["624ec4e5082c", "02188f091419"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["dea9ae4cf53d"], + "payloads": ["94d38f1838f6"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "32a7c0ae7918" }, - "state": "331b3596787f", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.outer-refused-no-message:launched", "observation": { - "sender": ["f1d579dd459c", "02188f091419"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["ae5875338dfe"], + "payloads": ["94d38f1838f6"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "30ec57518c05" }, - "state": "b13ddf18a62e", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.method-not-found:launched", "observation": { - "sender": ["12e70439f294", "02188f091419"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["a46a4a08b27d"], + "payloads": ["94d38f1838f6"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "b948e8307e81" }, - "state": "1963ee2edae9", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.transport-rejection:launched", "observation": { - "sender": ["dae756300589", "02188f091419"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["76bc1a917546"], + "payloads": ["94d38f1838f6"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "a947768bc0ed" }, - "state": "e35d7003f64c", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.transport-rejection-no-message:launched", "observation": { - "sender": ["1321b4c8c0b8", "02188f091419"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["6bf6a76b4bf4"], + "payloads": ["94d38f1838f6"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "c7584e82c72f" }, - "state": "b13ddf18a62e", + "state": "a4273b38df83", "effects": [] } } 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 5cbf3453358..742a1fd1da6 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 @@ -3,29 +3,168 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "39923c0faa941865cc9aead881fef91ce5db117120033fe73665356a61a2ecc6", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", + "scenarioSha256": "a288e105a0af6dbbd9657b84f7c4860826184fb7f50dabc6e31b114a70d7ca44", "platform": "darwin", "scenarioVersion": 1, "projectionVersion": 2, "goldenFormatVersion": 5, "values": { - "02188f091419": { - "name": "settings.get#1", - "ordinal": 2, + "0fddcb81d17c": { + "name": "terminal.send#1", + "ordinal": 3, "args": [ { "name": "method", - "value": "settings.get" + "value": "terminal.send" }, { "name": "params", + "value": { + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" + } + }, + { + "name": "options", "value": { "$rpc": "absent" } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "error": { + "code": "refused", + "message": "outer refused" + }, + "id": "frame-2", + "ok": false + } + } + }, + "1953443d1eef": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "The host sent a reply this app could not read (terminal.send)", + "isRpcDeliveryUnknown": false + } + }, + "280ce0d6f832": { + "name": "terminal.send#1", + "ordinal": 4, + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"term-1\",\"text\":\"Fix the failing checks\",\"enter\":true}}" + }, + "32a7c0ae7918": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "outer refused", + "isRpcDeliveryUnknown": false + } + }, + "5093e98ba369": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, + "args": [ + { + "name": "method", + "value": "session.tabs.createTerminal" + }, + { + "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-1", + "ok": true, + "result": { + "tab": { + "id": "tab-1", + "terminal": "term-1", + "title": "Agent", + "type": "terminal" + } + } + } + } + }, + "5101a111b6fc": { + "name": "terminal.send#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true + } + } + }, + "52f741886b88": { + "name": "terminal.send#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" + } }, { "name": "options", @@ -42,27 +181,139 @@ "id": "frame-2", "ok": true, "result": { - "settings": { - "defaultTuiAgent": "codex", - "disabledTuiAgents": [] - } + "error": "refused" } } } }, - "0ba58e9fadf7": { - "name": "settings.get#1", - "ordinal": 2, + "6ccc8bb71fd9": { + "name": "terminal.send#1", + "ordinal": 3, "args": [ { "name": "method", - "value": "settings.get" + "value": "terminal.send" }, { "name": "params", + "value": { + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" + } + }, + { + "name": "options", "value": { "$rpc": "absent" } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true + } + } + }, + "75252c821440": { + "name": "terminal.send#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "id": "frame-2", + "ok": true, + "result": { + "$rpc": "null" + } + } + } + }, + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "94d38f1838f6": { + "name": "session.tabs.createTerminal#1", + "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:repo-9::/w\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}" + }, + "94fd7b6e1923": { + "name": "terminal.send#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" + } + }, + { + "name": "options", + "value": { + "$rpc": "absent" + } + } + ], + "settlement": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true + } + } + }, + "9d6948ff3085": { + "name": "terminal.send#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" + } }, { "name": "options", @@ -85,18 +336,33 @@ } } }, - "0ca529648be4": { - "name": "settings.get#1", - "ordinal": 2, + "a21f478354cc": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Failed to send prompt", + "isRpcDeliveryUnknown": false + } + }, + "a4273b38df83": { + "launched": "unlaunched" + }, + "a89e0dd4a790": { + "name": "terminal.send#1", + "ordinal": 3, "args": [ { "name": "method", - "value": "settings.get" + "value": "terminal.send" }, { "name": "params", "value": { - "$rpc": "absent" + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" } }, { @@ -107,39 +373,53 @@ } ], "settlement": { - "status": "rejected", + "status": "fulfilled", "startedAt": 0, "settledAt": 0, - "error": { - "category": "Error", - "message": "", - "isRpcDeliveryUnknown": true + "value": { + "id": "frame-2", + "ok": true, + "result": { + "error": "inner refused", + "ok": false + } } } }, - "1963ee2edae9": { - "launched": { - "kind": "not-started", - "message": "Unknown method" + "a947768bc0ed": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "transport failure", + "isRpcDeliveryUnknown": true } }, - "1c2564f1daca": { - "name": "settings.get#1", - "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + "b948e8307e81": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "Unknown method", + "isRpcDeliveryUnknown": false + } }, - "1c5359ea3f7d": { - "name": "settings.get#1", - "ordinal": 2, + "bccc77511918": { + "name": "terminal.send#1", + "ordinal": 3, "args": [ { "name": "method", - "value": "settings.get" + "value": "terminal.send" }, { "name": "params", "value": { - "$rpc": "absent" + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" } }, { @@ -165,29 +445,20 @@ } } }, - "28339abecf78": { - "name": "agent.launchReplay#1", - "ordinal": 8, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"agent.launchReplay\",\"params\":{\"agent\":\"codex\",\"operationId\":\"1767225600000-00000000000040008000000000000001\",\"target\":{\"kind\":\"existing\",\"worktree\":\"id:repo-9::/w\"},\"prompt\":{\"text\":\"Fix the failing checks\",\"delivery\":\"submit\"},\"launchSource\":\"task_page\"}}" - }, - "331b3596787f": { - "launched": { - "kind": "not-started", - "message": "outer refused" - } - }, - "34f3a5b8bd78": { - "name": "settings.get#1", - "ordinal": 2, + "bf156998a035": { + "name": "terminal.send#1", + "ordinal": 3, "args": [ { "name": "method", - "value": "settings.get" + "value": "terminal.send" }, { "name": "params", "value": { - "$rpc": "absent" + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" } }, { @@ -211,387 +482,14 @@ } } }, - "35f85fe3b71c": { - "name": "repo.list#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "repo.list" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "3e6a0417d2ee": { - "name": "repo.list#1", - "ordinal": 1, - "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-1", - "ok": true, - "result": { - "repos": [ - { - "id": "repo-9" - } - ] - } - } - } - }, - "51d4cb56be85": { - "name": "settings.get#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "566d8202b582": { - "name": "settings.get#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "rejected", - "startedAt": 0, - "settledAt": 0, - "error": { - "category": "Error", - "message": "transport failure", - "isRpcDeliveryUnknown": true - } - } - }, - "69518e60477b": { - "name": "settings.get#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "$rpc": "null" - } - } - } - }, - "753cc167886c": { - "launched": { - "kind": "not-started", - "message": "Cannot read properties of null (reading 'settings')" - } - }, - "86798e4821d1": { - "name": "settings.get#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "error": "inner refused", - "ok": false - } - } - } - }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 - }, - "98bd3a5fda2b": { - "name": "settings.get#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true - } - } - }, - "9b538e8b95f3": { - "launched": { - "kind": "not-started", - "message": "Cannot read properties of undefined (reading 'settings')" - } - }, - "9c16efc31903": { - "name": "preflight.detectAgents#1", - "ordinal": 5, - "args": [ - { - "name": "method", - "value": "preflight.detectAgents" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-3", - "ok": true, - "result": ["codex"] - } - } - }, - "a4273b38df83": { - "launched": "unlaunched" - }, - "b13ddf18a62e": { - "launched": { - "kind": "not-started", - "message": "Could not load the available agents." - } - }, - "ba3fac1eb199": { - "name": "agent.launchReplay#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "agent.launchReplay" - }, - { - "name": "params", - "value": { - "agent": "codex", - "launchSource": "task_page", - "operationId": "1767225600000-00000000000040008000000000000001", - "prompt": { - "delivery": "submit", - "text": "Fix the failing checks" - }, - "target": { - "kind": "existing", - "worktree": "id:repo-9::/w" - } - } - }, - { - "name": "options", - "value": { - "timeoutMs": 90000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", - "ok": true, - "result": { - "outcome": { - "handle": "term-1", - "kind": "terminal" - }, - "prompt": { - "delivery": "submit", - "outcome": "handed-to-terminal" - }, - "receipt": { - "detail": "Started a terminal agent, the default for new agent tabs in your settings.", - "mode": "terminal", - "preferred": "terminal", - "reason": "user_default" - }, - "worktreeId": "repo-9::/w" - } - } - } - }, - "ceb5fe72c101": { - "name": "settings.get#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "error": { - "code": "refused", - "message": "outer refused" - }, - "id": "frame-2", - "ok": false - } - } - }, - "e0eef7c9fafe": { - "name": "repo.list#1", - "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "e35d7003f64c": { - "launched": { - "kind": "not-started", - "message": "transport failure" + "c7584e82c72f": { + "status": "rejected", + "startedAt": 0, + "settledAt": 0, + "error": { + "category": "Error", + "message": "", + "isRpcDeliveryUnknown": true } }, "eb79a9b3682a": { @@ -602,29 +500,53 @@ "$rpc": "undefined" } }, - "eb8d47f90956": { - "launched": { - "kind": "sent" - } - }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" - }, - "fb9f08890f70": { - "name": "settings.get#1", - "ordinal": 2, + "f9b284ac2424": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, "args": [ { "name": "method", - "value": "settings.get" + "value": "session.tabs.createTerminal" }, { "name": "params", + "value": { + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" + } + }, + { + "name": "options", "value": { "$rpc": "absent" } + } + ], + "settlement": { + "status": "pending", + "startedAt": 0 + } + }, + "fe1fe746e77a": { + "launched": "sent" + }, + "fe53d3bce307": { + "name": "terminal.send#1", + "ordinal": 3, + "args": [ + { + "name": "method", + "value": "terminal.send" + }, + { + "name": "params", + "value": { + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" + } }, { "name": "options", @@ -641,20 +563,22 @@ "id": "frame-2", "ok": true, "result": { - "error": "refused" + "send": { + "accepted": true + } } } } } }, "recording": { - "scenario": "matrix-session.pr-triage-settings.get-1", + "scenario": "matrix-session.pr-triage-terminal.send-1", "checkpoints": [ { "id": "pr-triage-launch.prelude:pending", "observation": { - "sender": ["35f85fe3b71c", "51d4cb56be85"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["f9b284ac2424"], + "payloads": ["94d38f1838f6"], "settlements": { "launch": "9270aeb7d9c6" }, @@ -665,132 +589,132 @@ { "id": "pr-triage-launch.normal:launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["5093e98ba369", "fe53d3bce307"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { "launch": "eb79a9b3682a" }, - "state": "eb8d47f90956", + "state": "fe1fe746e77a", "effects": [] } }, { "id": "pr-triage-launch.result-absent:launched", "observation": { - "sender": ["3e6a0417d2ee", "98bd3a5fda2b", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["5093e98ba369", "5101a111b6fc"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "1953443d1eef" }, - "state": "9b538e8b95f3", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.result-null:launched", "observation": { - "sender": ["3e6a0417d2ee", "69518e60477b", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["5093e98ba369", "75252c821440"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "1953443d1eef" }, - "state": "753cc167886c", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.inner-ok-missing:launched", "observation": { - "sender": ["3e6a0417d2ee", "fb9f08890f70", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["5093e98ba369", "52f741886b88"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { "launch": "eb79a9b3682a" }, - "state": "eb8d47f90956", + "state": "fe1fe746e77a", "effects": [] } }, { "id": "pr-triage-launch.inner-false-string-error:launched", "observation": { - "sender": ["3e6a0417d2ee", "86798e4821d1", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["5093e98ba369", "a89e0dd4a790"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { "launch": "eb79a9b3682a" }, - "state": "eb8d47f90956", + "state": "fe1fe746e77a", "effects": [] } }, { "id": "pr-triage-launch.inner-false-object-error:launched", "observation": { - "sender": ["3e6a0417d2ee", "1c5359ea3f7d", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["5093e98ba369", "bccc77511918"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { "launch": "eb79a9b3682a" }, - "state": "eb8d47f90956", + "state": "fe1fe746e77a", "effects": [] } }, { "id": "pr-triage-launch.outer-refused:launched", "observation": { - "sender": ["3e6a0417d2ee", "ceb5fe72c101", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["5093e98ba369", "0fddcb81d17c"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "32a7c0ae7918" }, - "state": "331b3596787f", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.outer-refused-no-message:launched", "observation": { - "sender": ["3e6a0417d2ee", "34f3a5b8bd78", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["5093e98ba369", "bf156998a035"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "a21f478354cc" }, - "state": "b13ddf18a62e", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.method-not-found:launched", "observation": { - "sender": ["3e6a0417d2ee", "0ba58e9fadf7", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["5093e98ba369", "9d6948ff3085"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "b948e8307e81" }, - "state": "1963ee2edae9", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.transport-rejection:launched", "observation": { - "sender": ["3e6a0417d2ee", "566d8202b582", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["5093e98ba369", "6ccc8bb71fd9"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "a947768bc0ed" }, - "state": "e35d7003f64c", + "state": "a4273b38df83", "effects": [] } }, { "id": "pr-triage-launch.transport-rejection-no-message:launched", "observation": { - "sender": ["3e6a0417d2ee", "0ca529648be4", "9c16efc31903"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1"], + "sender": ["5093e98ba369", "94fd7b6e1923"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { - "launch": "eb79a9b3682a" + "launch": "c7584e82c72f" }, - "state": "b13ddf18a62e", + "state": "a4273b38df83", "effects": [] } } 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 e4f6a23b552..c527ee5d6df 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 @@ -3,7 +3,7 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 17ce0964263..85ff63d1380 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 @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 f648c2e8ac1..478e6a02d9f 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 @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 a0f762185fd..517dc85458e 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 @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", 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 99d66d2defe..333dd05532f 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 @@ -3,10 +3,10 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "651cb070a4b4ba7deddeb37c08886094b83f006e7cbe2bef65d5433ce0430dff", "platform": "darwin", "scenarioVersion": 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 58802f4f895..99b29c02050 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 @@ -3,10 +3,10 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "e0ce643010302d304ba3611bd43932c6cdd0fa9290a3ecd4fffb4fc92c758b77", "platform": "darwin", "scenarioVersion": 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 62e043af47f..155da273f95 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 @@ -3,10 +3,10 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "c6b3b4be6b55daf53ee1f5ff755e73a032c4da5379f90f4973736c032eb79414", "platform": "darwin", "scenarioVersion": 1, 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 362dffdc76c..814dc73c721 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 @@ -3,10 +3,10 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "a71815661dee6129a1b133c398b37ea89a9e4939b45626dada16889acabb2afe", "platform": "darwin", "scenarioVersion": 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 ac352e0089b..0b7469973d7 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 @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", 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 9f541d66106..a16c896c67c 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 @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", 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 31b9ff28473..d204255a15c 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 @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", 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 5c8e41f20d1..cb428c4052e 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 @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", 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 238f591fc1e..df02aacec85 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 @@ -3,7 +3,7 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 6f4190fa1eb..e597a6f9f16 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 @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 307f75b8d3a..b9ac10cf846 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 @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 cb1bd418c6b..c40cd3ce1d7 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 @@ -3,7 +3,7 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 6898ce2ba4c..61a4f4d6888 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 @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 698195c1313..7df475361a6 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 @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 47933d9f7e1..a5ecdf8b11f 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 @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", 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 65f81e78736..b3ded358b09 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 @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", 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 1f26dfaf440..4bfb3cabab2 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 @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", 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 d090f7a994c..ecd75782f2a 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 @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", 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 0a2ea1d1c65..4b946df5abc 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 @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", 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 a7aed3938b9..e1ab54e2c80 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 @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", 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 d518ebf76f0..4914564da47 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 @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", 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 8c9ed94cb48..66c429b0b5a 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 @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 a5d75ef79ab..3a3a461aa6c 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 @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", 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 f2b0fa4b3d8..5fe5ca6205d 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 @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", 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 6842783bd23..e00618c2588 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 @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", 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 363833f0be0..f55a57ea30f 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 @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", 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 417da52d13a..b609575e82b 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 @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", 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 a56826b607f..e2c2a2758fe 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 @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 846bc2a00db..9b07de54686 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 @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 ed6e0d81da2..928a5696fe5 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 @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 b8112c2126d..c2338edc944 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 @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", 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 adc0b94117b..8787f9554dd 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 @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 11b00b6159d..242f9a062fa 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 @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 fa4a466256d..962c677e961 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 @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 7ce3c6163f4..9ff81d7ad4c 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 @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 6d0260c6a4a..8dc8f21eee7 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 @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 5068cb41eb5..726c2e195b6 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 @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 cf389057990..a07fee8e62b 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 @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 7e86d0a0835..ee6f101dc92 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 @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", 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 f591651d45c..79cfe065443 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 @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", 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 0ec91202989..bad9a30e3fc 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 @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 63b043e41ee..4d7b5884e1d 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 @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 6c93c170028..f536a3066a8 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 @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 24632aa10c5..89a64b9a3f3 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 @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 aba4286e43e..a8a4078b3cf 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 @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 dd5fab82ab3..58c72c88352 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 @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 6a0854e0f9b..845f1980325 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 @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 58c7f7ef066..f1add389a64 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 @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 16a444e9bce..9ac803e5454 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 @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 9a48d52024d..59f98f66562 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 @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 7f1f0d9f26c..65ff795fd27 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 @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 f7112f138cf..addcac90742 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 @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 38451c82c99..1acf99da567 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 @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 b1efd15f22e..fd1bc8d0aac 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 @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 85f16c0d459..9eeab114d69 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 @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", 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 da224d51e67..7b47f7e06cb 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 @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", 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 1973602a21d..abbe895d7b5 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 @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", 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 64f7e03c033..61fab66b2eb 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 @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 c73d8785983..b73f7398e80 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 @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 e018d705320..a958e3713eb 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 @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 ce1ae3a9283..878f43bf3b2 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 @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 481d68b75ac..a876a198ba6 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 @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", 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 64082d8ffbd..72181fa566b 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 @@ -3,7 +3,7 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 5c32eb03d8d..3471fb8e012 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 @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 00ebea7c3b1..c72dcfe532f 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 @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 27712174dd2..b8d799efd24 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 @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 d920e80d3b5..f33b22916ac 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 @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 927bdb4fd56..0a0c8289994 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 @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 7f6e7e124b5..86ba9fcd470 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 @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 8a099921810..3409286301b 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 @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 70fcb78ffe1..c53ad0dc95b 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 @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 ecb6b8a0217..b67b7af9961 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 @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", 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 6981cc729cc..21054a03d14 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 @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", 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 f3757724a17..320e22d281e 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 @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", 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 d2b6622eea7..8e6b46a0e8d 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 @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", 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 cd3ced4e052..b03c44c060a 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 @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", 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 5068f94d501..95155ad4167 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 @@ -3,7 +3,7 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 e4b2a8af519..031fe3d00f9 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 @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 e08e020433e..d11a120012f 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 @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 d33949bad5b..dfde030a09c 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 @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", 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 18bc7dec6b2..7d0b9706063 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 @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", 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 f393641fdf1..443860d4591 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 @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", 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 685d0b1208b..eca351d61b2 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 @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", 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 360c081f1d0..ad93b3ea025 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 @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", 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 86f9ee8f4d4..96bc25cec25 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 @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", 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 6df54d4a26e..cbb4de4f6bc 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 @@ -3,7 +3,7 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 6db47baeb4f..9bcdd680869 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 @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", 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 5d2f9fa5977..077b75b17d5 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 @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", 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 de0ed86bfe1..0b807b5ed22 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 @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", 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 b04d9107a74..9a9603a2811 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 @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 d11db1de026..a474ac6a30b 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 @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 f7e12d8f92a..b3dda42772f 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 @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 c8b92aa5cf7..405d54c6c10 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 @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 3e31f170e52..8e6247874d2 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 @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 a84037b6519..ded345480f9 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 @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 69ef1966d01..c087e542b0f 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 @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", 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 fff2dbe5fcd..281a3242759 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 @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", 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 5c8b86d6d88..5e7e0f49501 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 @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", 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 432094b9878..c593debdb83 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 @@ -3,7 +3,7 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 759080673cc..7007ebff918 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 @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", 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 882389dd42b..1aba541766f 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 @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", 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 f2b2c120fe1..0d1d7b376ca 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 @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", 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 00bb488aee1..d6fa613a3d9 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 @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", 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 639952affde..b0424264674 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 @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", 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 6c456ce2641..13d5fb72afa 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 @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 276f85ba3bf..8ca021156ec 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 @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 94b6cb74a94..2d182d0a0d9 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 @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 2ad048b2de6..a06701de86e 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 @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 8f3912559e3..0fbdd02524a 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 @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", 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 9d1d1ef11e9..ff472ef2594 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 @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", 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 1ec97ad0c09..1e07891bcd8 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 @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", 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 dddd1447932..ccd800b3001 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 @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", 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 52ff8d41ee0..e3a2bb53251 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 @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", 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 79eaf54c9be..9a2392eb024 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 @@ -3,7 +3,7 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", 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 78aebc2e07b..9e4ad592386 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", 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 8dc0fc72f71..f26251dd627 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", 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 a83a325f561..77f447daa8d 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", 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 fc5202acfc0..8d8ecf09a95 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", 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 6004de1431e..e210a9781f1 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", 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 f9bf237b54d..ee80800548e 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", 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 f7cab7ece3d..29a69fc4ecd 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", 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 734fe9900d1..396a5ef5a7a 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", 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 1052d81db8a..348fa5d00ba 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", 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 5fbd3d2773b..d43f59244e4 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", 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 82fa4792393..e7e09e99bff 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", 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 88745a3f068..01e655d36ea 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", 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 42cddb38225..cf97438c945 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", 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 7de55aa8eb1..94f6d229695 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", 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 740b0a028a9..dd49f3079ea 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", 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 7ea4f779249..7d1fcbc4ae3 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", 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 88022cb7ead..840421c8cf0 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", 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 3cfb68aab93..7a4bc5ee2f8 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", 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 1927ca2dc07..4f65f491d23 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", 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 3b83d1635ca..d8ea88595a7 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", 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 e9b4a7a6054..6fa2add9b38 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", 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 041bca0aa3d..7cdf2f5e692 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", 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 66a12eb6585..5f1530f5584 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", 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 c7bc57030c9..fe59a15359a 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 @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", 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 58a2822af4d..1ba3862369c 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 @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 ca2b7ec18b1..57ed5a30f4b 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 @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 f50104347bf..7b3c99dd7d5 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 @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 c9f1e7726bf..34dde76dcc5 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 @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 77c11ef1955..193eb179481 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 @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 1fc5966ae3d..37c8aa87aad 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 @@ -3,7 +3,7 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "23f974df8b57c723069605de1db80c339ead286b18aae38e67fce03ea50c5180", 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 8aa334ab5ed..1b3d8a1fb84 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 @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 f63f836e1f0..9d4c9c75b92 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 @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 e2e7c66f06c..de1e8ec39ec 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 @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 8a34fe85071..f8e8159ffa7 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 @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 659d6e610b4..2f560b10ebb 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 @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 a82c92af99a..fca4fce1197 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 @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 28b95c786f3..fa23c0c10fb 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 @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 fdafa9a4c21..91964a45c8a 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 @@ -3,7 +3,7 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 082c0b9088a..098d6ee570f 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 @@ -3,7 +3,7 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 7a509ecb368..ac71243b86c 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 @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 111a130a787..02a4a520525 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 @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 82d115bd57c..39c5056378b 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 @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 094182f3dc3..07b47c3b875 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 @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 5d428512d71..f5568be4258 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 @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 e3ac9fd6051..164dbc2c385 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 @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 2bcd37b0287..0e4bb302087 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 @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 63f204d49b0..4626bfa69e4 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 @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 e17de8fdd73..bbd02ec0d5c 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 @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 236a4072137..83cb2175e33 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 @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 c277e9fc7d9..328d3a78b15 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 @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 219a1ee1420..bc40f5401eb 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 @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 9b4173f44fd..c821da19b59 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 @@ -3,10 +3,10 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "15b5694a63d54aeb4f4d9a861729e7e3b42ce06b15af6784fcb1afab744ed18b", "platform": "darwin", "scenarioVersion": 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 9d5eaf9a301..30d583e8a30 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 @@ -3,10 +3,10 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "68c039f74da08523d67d8d5b0a178c1d12507ec88d25891cd1377cd3fb40205c", "platform": "darwin", "scenarioVersion": 1, 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 85214afde8c..5204b12e3a8 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 @@ -3,10 +3,10 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "19d55a77e9c2f64f062070c9985f756e0ba72f3ccc3c884a80843fe888f42a47", "platform": "darwin", "scenarioVersion": 1, 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 1184de9497c..f505e5796bf 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 @@ -3,10 +3,10 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "6913de3f553b47a7e6663e21b0b93e97df26405c210db876f0b9593bd38936be", "platform": "darwin", "scenarioVersion": 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 6dc587eba94..b9c439efcb8 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 @@ -3,10 +3,10 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "da13ec556c0f672371a8cd2aabd4dcc68001c0f9aa47015dfa4b5937355c4c2c", "platform": "darwin", "scenarioVersion": 1, 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 2df5f72aef4..2a108be84ca 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 @@ -3,10 +3,10 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "bde05ca916393d7f5ccd48686cf13a52fb2dfa599ea874b084c58bd59adb7e7f", "platform": "darwin", "scenarioVersion": 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 62f2b1af669..6db96292ba9 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 @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 c4c0131d991..c8b59e0185e 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 @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 fe6a6126dbd..79cb47a8caa 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 @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 36bb3f8ea29..8f82062e017 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 @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 f84a5424232..eefc85fe1a2 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 @@ -3,11 +3,11 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "b3d017cee84fc133ab0b0a8d588750f8af907d4688e6c2900a307de25fab8473", + "scenarioSha256": "887b5d16b4bee027edf5e2f1122a79a34b223f6926322a99f6e3de83f40bf6ee", "platform": "darwin", "scenarioVersion": 1, "projectionVersion": 2, 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 1d6226a7814..ae509cdf525 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 @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", 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 014af279bb3..ffe4a7ef2a8 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 @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 e437713a891..61a36e1b326 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 @@ -3,7 +3,7 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", 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 021d98847be..23a6359dd93 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 @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 d038aaf1b3c..4bbc628922c 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 @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 9f23f542901..1ea68b39078 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 @@ -3,7 +3,7 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", 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 145703a0e5a..a388f5c0775 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 @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 9589c64b1ba..d26247dff58 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 @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 c9b20fab63c..2596e969072 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 @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 1f55833f9f4..04df881a60a 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", 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 69cfbab2baf..d194ccbaf4d 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", 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 da335beff11..71c57191f47 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-manifest", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", diff --git a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json index 17a8f57e712..9467539ba39 100644 --- a/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json +++ b/mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json @@ -3,7 +3,7 @@ "family": "mobileWeb.bundle-fetch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "af339fef2c684d5709c6d3f279e5f0d9c33d17b6d4e5c89e501963400901b564", 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 b1df0cb105d..c600880dba7 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-paste-single.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 116955aa7ca..77c453270f5 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 @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 08a6fd86045..e2e1c2d81f9 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 @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 1661d13214a..fa156646e3e 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 @@ -3,7 +3,7 @@ "family": "nativeChat.image-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 f075807a1ff..2603c1af994 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 5b923b6bdf6..d3022a44345 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 @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 aa6ec6be75e..6efedc9f7f9 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-single.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 8028f115eac..eec517f79f4 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 @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", 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 c2c743a6dbc..ee8acc72bf0 100644 --- a/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json +++ b/mobile/rpc-foundation/goldens/native-chat-image-upload-two.json @@ -3,7 +3,7 @@ "family": "nativeChat.image-upload", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "16779b663f4faec8cbccdb42efd7b568a2912845b161f8a4827754687eb4235c", diff --git a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json index 21bf3c07991..4622a00d452 100644 --- a/mobile/rpc-foundation/goldens/native-chat-page-earlier.json +++ b/mobile/rpc-foundation/goldens/native-chat-page-earlier.json @@ -3,7 +3,7 @@ "family": "session.native-chat-page", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "60ce67f66134d6385aa7fb47f2135436a7b3ce0221e3b2514bea65e06dba5518", 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 3bcfc1e6b3a..5d7e2a8b35e 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json index 9837bcd0670..c95d2e51fda 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-refused.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-refused.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 9d124e6b324..9082a47d732 100644 --- a/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json +++ b/mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json @@ -3,7 +3,7 @@ "family": "session.native-chat-readability", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 82236b78669..2485293b4a1 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 @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", 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 35e1cff0ca2..5aa52c6bd24 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 @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", 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 c07575ddaf3..cebae58375e 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 @@ -3,7 +3,7 @@ "family": "nativeChat.session-option-pick", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json index d6494cbeee5..aaadf20e074 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-accepted.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 fdc48871739..dfed38ee14f 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 cf2ce77ae0a..2c966e243ce 100644 --- a/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json @@ -3,7 +3,7 @@ "family": "session.native-chat-stop", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json index d49ef50d80d..8e934e9492b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-accepted.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-accepted.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", 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 19c7df9d454..55168585b18 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-clear-line.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", 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 7b07572022d..5544bb4877b 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json index 19b84ac122b..90d3789a371 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-rejected.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-rejected.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", 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 b0706a82713..5d9bcbd1c1d 100644 --- a/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json +++ b/mobile/rpc-foundation/goldens/native-chat-write-typed-command.json @@ -3,7 +3,7 @@ "family": "nativeChat.terminal-write", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b98a3ca3818678b88cf629bc4635300a103637126a136f025a1600dc16f08008", diff --git a/mobile/rpc-foundation/goldens/new-tab-local-agents.json b/mobile/rpc-foundation/goldens/new-tab-local-agents.json index d21c1b30223..27e7b91e5ca 100644 --- a/mobile/rpc-foundation/goldens/new-tab-local-agents.json +++ b/mobile/rpc-foundation/goldens/new-tab-local-agents.json @@ -3,7 +3,7 @@ "family": "settings.new-tab-local-agents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json index 08a038d5e02..d77510ab2b1 100644 --- a/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json +++ b/mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json @@ -3,7 +3,7 @@ "family": "components.new-workspace-repositories", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "64c1772f0f95a3c43fbb14398a8804b2b4784f7f18874e4fd79767ae634c7faa", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json index 2e25b68d67b..893a8ccbc6f 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json index ce0c297a5d6..3adabb6ea40 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json index 97a8a7df36b..2095440edb6 100644 --- a/mobile/rpc-foundation/goldens/notifications-desktop-stream.json +++ b/mobile/rpc-foundation/goldens/notifications-desktop-stream.json @@ -3,7 +3,7 @@ "family": "notifications.desktop-stream", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "eacf859143588ae6bee2804975d642b6c1088d57ae77ffe298620250d9a9f0e4", diff --git a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json index e329c7c5dba..40d0e5a55b6 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-accepted.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", 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 d82fd8859a8..623c63fc50a 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", 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 bfce5308101..fe0f81c73c9 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", 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 b22b00dc7c6..1efb5baabdc 100644 --- a/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json +++ b/mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json @@ -3,7 +3,7 @@ "family": "notifications.display-test-screen", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f55275c268e49679b37d72ce6cbe71186ce02c77fe3fd53f0f21e796502d62e8", diff --git a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json index e8fb49db6c9..8ff714c172e 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json +++ b/mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", diff --git a/mobile/rpc-foundation/goldens/notifications-push-registered.json b/mobile/rpc-foundation/goldens/notifications-push-registered.json index 22ffafe7395..e355038f68e 100644 --- a/mobile/rpc-foundation/goldens/notifications-push-registered.json +++ b/mobile/rpc-foundation/goldens/notifications-push-registered.json @@ -3,7 +3,7 @@ "family": "notifications.push-registration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "2e3d939dc162dbc5a38d8a7207111688204a825fd70348721917b3016e1c9470", 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 fc644cc8e0b..d2fa56dad74 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 @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", 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 f3244912bb7..49dabc4919c 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 @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", 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 ea1db64ea4d..5d4f2a3b7e1 100644 --- a/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json +++ b/mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json @@ -3,7 +3,7 @@ "family": "pairing.pre-profile", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", diff --git a/mobile/rpc-foundation/goldens/pr-branch-identity.json b/mobile/rpc-foundation/goldens/pr-branch-identity.json index 482303ad436..f66cf79630a 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-identity.json +++ b/mobile/rpc-foundation/goldens/pr-branch-identity.json @@ -3,10 +3,10 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "d1b208a7bee947a603949fdc1f0d145e8c5576926f89f3e32330585f3a115290", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json index f22e73e4d76..82069390082 100644 --- a/mobile/rpc-foundation/goldens/pr-branch-repo-context.json +++ b/mobile/rpc-foundation/goldens/pr-branch-repo-context.json @@ -3,10 +3,10 @@ "family": "session.pr-branch-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "bd715681a856254e0c374b504cece07e5df4c9a75fa2b97c35498df12210fbab", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-comment-mutation.json b/mobile/rpc-foundation/goldens/pr-comment-mutation.json index 6633a299644..4f4943938a7 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-comment-mutation.json @@ -3,10 +3,10 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "73cc5da2649b9687bb0d8247fa4ecf3c085746cf398515e8d2b40be2ed0da688", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json index d75fbf81976..2be5190aebb 100644 --- a/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json @@ -3,10 +3,10 @@ "family": "github.pr-comment-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "f425131d29fee8826c00a550fb537d0bd1a37bbaf3b33992984d5e04a990a512", "platform": "darwin", "scenarioVersion": 1, 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 93e75da0432..feafcb833d3 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json @@ -3,10 +3,10 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "70a94f45dbf7d58d9024c1cc4c94edd98fa48cd5ccffe8f2b7c53fa3e55d18d3", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-mutation-status.json b/mobile/rpc-foundation/goldens/pr-mutation-status.json index 12ac5703b63..2a139a74c95 100644 --- a/mobile/rpc-foundation/goldens/pr-mutation-status.json +++ b/mobile/rpc-foundation/goldens/pr-mutation-status.json @@ -3,10 +3,10 @@ "family": "github.pr-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "e9291209234bacab12201f4c13bb592e06bd405215389e19f0754c23e79eb197", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json index de32121b3b1..c715b9fcc2c 100644 --- a/mobile/rpc-foundation/goldens/pr-read-fork-routing.json +++ b/mobile/rpc-foundation/goldens/pr-read-fork-routing.json @@ -3,10 +3,10 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "ba21f7fc3966cb1e3c1e59d6e8a8b184fa0559bf3037365391ce23e364befd7f", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-read-surface.json b/mobile/rpc-foundation/goldens/pr-read-surface.json index 5d428472b11..68f5d6391b4 100644 --- a/mobile/rpc-foundation/goldens/pr-read-surface.json +++ b/mobile/rpc-foundation/goldens/pr-read-surface.json @@ -3,10 +3,10 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "4246e7ffa62e85aac489c561171a6468f862b7f18eefac9926b65073616b6d35", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json index fe578c8c622..81fc076d446 100644 --- a/mobile/rpc-foundation/goldens/pr-read-upstream-error.json +++ b/mobile/rpc-foundation/goldens/pr-read-upstream-error.json @@ -3,10 +3,10 @@ "family": "github.pr-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "b2445b299e18664b698d659c5041860b8a253314687666ae467aab441ab07235", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json index 9f90a584783..cc88806225f 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/pr-sidebar-load.json b/mobile/rpc-foundation/goldens/pr-sidebar-load.json index 5268feb67bb..6164f9f3891 100644 --- a/mobile/rpc-foundation/goldens/pr-sidebar-load.json +++ b/mobile/rpc-foundation/goldens/pr-sidebar-load.json @@ -3,7 +3,7 @@ "family": "session.pr-sidebar", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "87ffa2daea415d2682f1112025b5bd9d52404c6fc7c239180a3ad2120678ff1f", diff --git a/mobile/rpc-foundation/goldens/pr-title-mutation.json b/mobile/rpc-foundation/goldens/pr-title-mutation.json index e995616885c..7ea0aadadca 100644 --- a/mobile/rpc-foundation/goldens/pr-title-mutation.json +++ b/mobile/rpc-foundation/goldens/pr-title-mutation.json @@ -3,10 +3,10 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "071e745453795d18d683aaab63e810783e3cee4927b47425c20a3d915397d0dd", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json index d227d588c7f..9c1f08180fd 100644 --- a/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json +++ b/mobile/rpc-foundation/goldens/pr-title-unconfirmed.json @@ -3,10 +3,10 @@ "family": "github.pr-title-mutation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", "scenarioSha256": "669260c675021b37252dc5023a7a78e4e90536c2f35d9a5da3e53778e6a0cf52", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/pr-triage-launch.json b/mobile/rpc-foundation/goldens/pr-triage-launch.json index 661f1ee932e..cf7698c10cf 100644 --- a/mobile/rpc-foundation/goldens/pr-triage-launch.json +++ b/mobile/rpc-foundation/goldens/pr-triage-launch.json @@ -3,101 +3,36 @@ "family": "session.pr-triage", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "5f65c5b318f4764b0f58a5e7b593333759a9cfa799db45b11190ac26f4083335", - "scenarioSha256": "b7c9498eea67df2f670911e3b868a44ba77833813c17e7800dba52ebff530350", + "adapterSha256": "f560ff1582487c118756c527faa1fb1f65e37ce22ca9573491fd4b2dbcedf38d", + "scenarioSha256": "8bb2ff4e899ee873289fed7c1ef12e7f9dba91949125b1f0e4336d378ceb071e", "platform": "darwin", "scenarioVersion": 1, "projectionVersion": 2, "goldenFormatVersion": 5, "values": { - "02188f091419": { - "name": "settings.get#1", - "ordinal": 2, - "args": [ - { - "name": "method", - "value": "settings.get" - }, - { - "name": "params", - "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-2", - "ok": true, - "result": { - "settings": { - "defaultTuiAgent": "codex", - "disabledTuiAgents": [] - } - } - } - } - }, - "1c2564f1daca": { - "name": "settings.get#1", + "280ce0d6f832": { + "name": "terminal.send#1", "ordinal": 4, - "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}" + "json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"term-1\",\"text\":\"Fix the failing checks\",\"enter\":true}}" }, - "28339abecf78": { - "name": "agent.launchReplay#1", - "ordinal": 8, - "json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"agent.launchReplay\",\"params\":{\"agent\":\"codex\",\"operationId\":\"1767225600000-00000000000040008000000000000001\",\"target\":{\"kind\":\"existing\",\"worktree\":\"id:repo-9::/w\"},\"prompt\":{\"text\":\"Fix the failing checks\",\"delivery\":\"submit\"},\"launchSource\":\"task_page\"}}" - }, - "35f85fe3b71c": { - "name": "repo.list#1", + "5093e98ba369": { + "name": "session.tabs.createTerminal#1", "ordinal": 1, "args": [ { "name": "method", - "value": "repo.list" + "value": "session.tabs.createTerminal" }, { "name": "params", "value": { - "$rpc": "absent" - } - }, - { - "name": "options", - "value": { - "$rpc": "absent" - } - } - ], - "settlement": { - "status": "pending", - "startedAt": 0 - } - }, - "3e6a0417d2ee": { - "name": "repo.list#1", - "ordinal": 1, - "args": [ - { - "name": "method", - "value": "repo.list" - }, - { - "name": "params", - "value": { - "$rpc": "absent" + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" } }, { @@ -115,27 +50,51 @@ "id": "frame-1", "ok": true, "result": { - "repos": [ - { - "id": "repo-9" - } - ] + "tab": { + "id": "tab-1", + "terminal": "term-1", + "title": "Agent", + "type": "terminal" + } } } } }, - "51d4cb56be85": { - "name": "settings.get#1", + "9270aeb7d9c6": { + "status": "pending", + "startedAt": 0 + }, + "94d38f1838f6": { + "name": "session.tabs.createTerminal#1", "ordinal": 2, + "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:repo-9::/w\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}" + }, + "a4273b38df83": { + "launched": "unlaunched" + }, + "eb79a9b3682a": { + "status": "fulfilled", + "startedAt": 0, + "settledAt": 0, + "value": { + "$rpc": "undefined" + } + }, + "f9b284ac2424": { + "name": "session.tabs.createTerminal#1", + "ordinal": 1, "args": [ { "name": "method", - "value": "settings.get" + "value": "session.tabs.createTerminal" }, { "name": "params", "value": { - "$rpc": "absent" + "activate": false, + "navigation": "caller", + "select": true, + "worktree": "id:repo-9::/w" } }, { @@ -150,22 +109,23 @@ "startedAt": 0 } }, - "9270aeb7d9c6": { - "status": "pending", - "startedAt": 0 + "fe1fe746e77a": { + "launched": "sent" }, - "9c16efc31903": { - "name": "preflight.detectAgents#1", - "ordinal": 5, + "fe53d3bce307": { + "name": "terminal.send#1", + "ordinal": 3, "args": [ { "name": "method", - "value": "preflight.detectAgents" + "value": "terminal.send" }, { "name": "params", "value": { - "$rpc": "absent" + "enter": true, + "terminal": "term-1", + "text": "Fix the failing checks" } }, { @@ -180,95 +140,15 @@ "startedAt": 0, "settledAt": 0, "value": { - "id": "frame-3", - "ok": true, - "result": ["codex"] - } - } - }, - "a4273b38df83": { - "launched": "unlaunched" - }, - "ba3fac1eb199": { - "name": "agent.launchReplay#1", - "ordinal": 7, - "args": [ - { - "name": "method", - "value": "agent.launchReplay" - }, - { - "name": "params", - "value": { - "agent": "codex", - "launchSource": "task_page", - "operationId": "1767225600000-00000000000040008000000000000001", - "prompt": { - "delivery": "submit", - "text": "Fix the failing checks" - }, - "target": { - "kind": "existing", - "worktree": "id:repo-9::/w" - } - } - }, - { - "name": "options", - "value": { - "timeoutMs": 90000 - } - } - ], - "settlement": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "id": "frame-4", + "id": "frame-2", "ok": true, "result": { - "outcome": { - "handle": "term-1", - "kind": "terminal" - }, - "prompt": { - "delivery": "submit", - "outcome": "handed-to-terminal" - }, - "receipt": { - "detail": "Started a terminal agent, the default for new agent tabs in your settings.", - "mode": "terminal", - "preferred": "terminal", - "reason": "user_default" - }, - "worktreeId": "repo-9::/w" + "send": { + "accepted": true + } } } } - }, - "e0eef7c9fafe": { - "name": "repo.list#1", - "ordinal": 3, - "json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}" - }, - "eb79a9b3682a": { - "status": "fulfilled", - "startedAt": 0, - "settledAt": 0, - "value": { - "$rpc": "undefined" - } - }, - "eb8d47f90956": { - "launched": { - "kind": "sent" - } - }, - "f49d184679d1": { - "name": "preflight.detectAgents#1", - "ordinal": 6, - "json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"preflight.detectAgents\"}" } }, "recording": { @@ -277,8 +157,8 @@ { "id": "pending", "observation": { - "sender": ["35f85fe3b71c", "51d4cb56be85"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca"], + "sender": ["f9b284ac2424"], + "payloads": ["94d38f1838f6"], "settlements": { "launch": "9270aeb7d9c6" }, @@ -289,12 +169,12 @@ { "id": "launched", "observation": { - "sender": ["3e6a0417d2ee", "02188f091419", "9c16efc31903", "ba3fac1eb199"], - "payloads": ["e0eef7c9fafe", "1c2564f1daca", "f49d184679d1", "28339abecf78"], + "sender": ["5093e98ba369", "fe53d3bce307"], + "payloads": ["94d38f1838f6", "280ce0d6f832"], "settlements": { "launch": "eb79a9b3682a" }, - "state": "eb8d47f90956", + "state": "fe1fe746e77a", "effects": [] } } 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 97282b9f6a6..7c910ca21b2 100644 --- a/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json +++ b/mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 81ae2dd73c9..45df1788131 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 @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 4d6c2d4a61e..222aaccca17 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 @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 931181c6925..09fe006b04b 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 @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json index b8a0a1048e9..afd8573259e 100644 --- a/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json +++ b/mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json @@ -3,7 +3,7 @@ "family": "notifications.push-dismissal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "595a3eb2994d0596b9fcd0707b175b4e978625053dfbc5c541b0350c0cbfb524", diff --git a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json index 4e93d3130e3..6f8973598fb 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-load-refused.json +++ b/mobile/rpc-foundation/goldens/quick-commands-load-refused.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", 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 7b6c2f7abea..6e5f97a0d5a 100644 --- a/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json +++ b/mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", 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 fab24c7eb7c..d0873be6ba1 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 @@ -3,7 +3,7 @@ "family": "settings.quick-commands", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json index ca3bc006d64..3a75dffa1a1 100644 --- a/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json +++ b/mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", 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 625f979ccc2..a3070e19e8f 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 @@ -3,7 +3,7 @@ "family": "relay.direct-upgrade", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", 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 e5fa243f256..71306eca520 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", 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 74d18004bab..f3f2cd6770b 100644 --- a/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json +++ b/mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json @@ -3,7 +3,7 @@ "family": "relay.pairing-recovery", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "57c8ad93d162ea69e4a0ecd5de7d8fae6806f8f7a51945c2c13661d81bae6497", 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 0b294268bfd..d889372f3a6 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", 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 f9dc4ceb930..551bbd469bf 100644 --- a/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json +++ b/mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json @@ -3,7 +3,7 @@ "family": "relay.credential-rotation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "374a16da4187ee44fcc6e07daf822ae96ecfc13ab47ee6c29d048b1c0bd0f291", diff --git a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json index a3a1b3852f7..989e935206e 100644 --- a/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-branch-diff-shapes.json @@ -3,7 +3,7 @@ "family": "session.review-branch-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json index 8fc47c4d997..4ac8364c774 100644 --- a/mobile/rpc-foundation/goldens/review-file-diff-shapes.json +++ b/mobile/rpc-foundation/goldens/review-file-diff-shapes.json @@ -3,7 +3,7 @@ "family": "session.review-file-diff", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a8016eb61915cf80a3bdeb622ee67d35be8b4b9862a75e4ef2e8f4ff8e93e7f2", diff --git a/mobile/rpc-foundation/goldens/review-git-mutations-run.json b/mobile/rpc-foundation/goldens/review-git-mutations-run.json index 7acd8380fe5..bbf3e6e4800 100644 --- a/mobile/rpc-foundation/goldens/review-git-mutations-run.json +++ b/mobile/rpc-foundation/goldens/review-git-mutations-run.json @@ -3,10 +3,10 @@ "family": "session.review-git-mutations", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "7981ac87c4397b9703330835327c70828cb5a76633f1c4803ded1b66520d184d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json index 8eab1e23c96..903c81db2eb 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json @@ -3,10 +3,10 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "9f1c39e91a97266b0a550d2b2d061592ae050078401ef23177dd2e00db67bf04", "platform": "darwin", "scenarioVersion": 1, 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 f2031e38a92..2123c983287 100644 --- a/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json +++ b/mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json @@ -3,10 +3,10 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "c858cfca59148548146b4c0a775e9518d3ab826ef268ccc9af60b04e52cc9e3d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-open-in-session.json b/mobile/rpc-foundation/goldens/review-open-in-session.json index b8757f5ecea..8762e9163ca 100644 --- a/mobile/rpc-foundation/goldens/review-open-in-session.json +++ b/mobile/rpc-foundation/goldens/review-open-in-session.json @@ -3,10 +3,10 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "2f0a6aa8ce100edbce9ccf64c81efe64356fccbdcd2e16f561360e2b11b5700f", "platform": "darwin", "scenarioVersion": 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 eaf1503e10a..c63f3eb7406 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 @@ -3,10 +3,10 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "7099d7311b2046dde21a2750201718995c0869bc4f17fb7f3ce2b997ce0e6506", "platform": "darwin", "scenarioVersion": 1, 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 1f9bb68760d..04e253838be 100644 --- a/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json +++ b/mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json @@ -3,10 +3,10 @@ "family": "session.review-send-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "8b4a06c1b2918d5ed5f40102c1c7cb411e02aba5e7601e98658d673634784bb0", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-stage-file.json b/mobile/rpc-foundation/goldens/review-stage-file.json index 8634fa1ab28..5ec53a81a4a 100644 --- a/mobile/rpc-foundation/goldens/review-stage-file.json +++ b/mobile/rpc-foundation/goldens/review-stage-file.json @@ -3,10 +3,10 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "793e954960d371c6477d4cd3d70a5215c0bd90764684eafdb1e1a2e09790cc86", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/review-stage-refused.json b/mobile/rpc-foundation/goldens/review-stage-refused.json index dd6bdfc908e..aef207226d1 100644 --- a/mobile/rpc-foundation/goldens/review-stage-refused.json +++ b/mobile/rpc-foundation/goldens/review-stage-refused.json @@ -3,10 +3,10 @@ "family": "session.diff-review-actions", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "dc3a746274b4197b02b0b3060bbf8ab0f67eb54355c8b9888e1f061e37f777af", + "adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc", "scenarioSha256": "46b720fed417d29f79dbde4cc7941579f2d6a2f920bae24234537480079e7de5", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-default.json b/mobile/rpc-foundation/goldens/sc-base-ref-default.json index 4e2104e33d8..6df3080de54 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-default.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-default.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 3dce9510b28..f3dc9ae7834 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json index 0fc1c6c0a92..1dbdb118320 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 70d9e423c1c..11a6308c70a 100644 --- a/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json +++ b/mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json @@ -3,7 +3,7 @@ "family": "git.base-ref-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json index 0a7b317274f..ba10deb68d4 100644 --- a/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json +++ b/mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json @@ -3,7 +3,7 @@ "family": "git.branch-diff-preview", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-changes-loaded.json b/mobile/rpc-foundation/goldens/sc-changes-loaded.json index fadad8fbc24..c016cf328bb 100644 --- a/mobile/rpc-foundation/goldens/sc-changes-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-changes-loaded.json @@ -3,7 +3,7 @@ "family": "git.changes-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", 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 6f99c95e161..f5400fc135f 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json index 09856117f2c..20e5cbead2d 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-canceled.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json index a7b6ea6a1cb..c1ed89ff9d2 100644 --- a/mobile/rpc-foundation/goldens/sc-commit-message-generated.json +++ b/mobile/rpc-foundation/goldens/sc-commit-message-generated.json @@ -3,7 +3,7 @@ "family": "git.commit-message-ai", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-create-existing-review.json b/mobile/rpc-foundation/goldens/sc-create-existing-review.json index 411186ec2e7..33de7f54a83 100644 --- a/mobile/rpc-foundation/goldens/sc-create-existing-review.json +++ b/mobile/rpc-foundation/goldens/sc-create-existing-review.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 7ba801fd133..e93034cccba 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 2ac7211e6ef..ad6511c94ea 100644 --- a/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json +++ b/mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-intent", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 ce5f1d6ee98..d2e07b8bd5f 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 @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 e7b52b81cbb..ec8982ed6dd 100644 --- a/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json +++ b/mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 7061bf38331..d6ac8f18856 100644 --- a/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 fcaa15921b6..1aa2aed3cad 100644 --- a/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json +++ b/mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json @@ -3,7 +3,7 @@ "family": "hostedReview.create-chain", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json index 829fe5fe68e..8c69f7504ae 100644 --- a/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json +++ b/mobile/rpc-foundation/goldens/sc-eligibility-fetched.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-history-commit-files.json b/mobile/rpc-foundation/goldens/sc-history-commit-files.json index 09a5888c92a..23f2847f93c 100644 --- a/mobile/rpc-foundation/goldens/sc-history-commit-files.json +++ b/mobile/rpc-foundation/goldens/sc-history-commit-files.json @@ -3,7 +3,7 @@ "family": "git.history-commit-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "48ccada93f208a24160483e98ee94a771ab6d63222f29ac4bbd6979157f98333", diff --git a/mobile/rpc-foundation/goldens/sc-history-loaded.json b/mobile/rpc-foundation/goldens/sc-history-loaded.json index 3b6c757d928..893640bc704 100644 --- a/mobile/rpc-foundation/goldens/sc-history-loaded.json +++ b/mobile/rpc-foundation/goldens/sc-history-loaded.json @@ -3,7 +3,7 @@ "family": "git.history-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 1e18cacab43..1feb3cb1814 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-read.json b/mobile/rpc-foundation/goldens/sc-pr-link-read.json index bcd724d7bbe..e275ba74231 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-read.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-read.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-pr-link-set.json b/mobile/rpc-foundation/goldens/sc-pr-link-set.json index 5ed54fbb2f5..97668379bf5 100644 --- a/mobile/rpc-foundation/goldens/sc-pr-link-set.json +++ b/mobile/rpc-foundation/goldens/sc-pr-link-set.json @@ -3,7 +3,7 @@ "family": "worktree.review-link", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 68636f1d20c..e387920494d 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 f3f53d95f2d..3215dff50cc 100644 --- a/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json +++ b/mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json @@ -3,7 +3,7 @@ "family": "hostedReview.eligibility", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 bab5a791629..26a535638b2 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json index d91e37030ad..0c9f8b46ab1 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-publish.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json index daacc58e7d7..5c78df7a2e0 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-push.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-push.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json index bef09d140f8..d39070adb22 100644 --- a/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json +++ b/mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json @@ -3,7 +3,7 @@ "family": "git.remote-prerequisite", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json index 70ab595f98a..519db73bcb3 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-first-poll.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", diff --git a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json index fb206b872a9..feb0b6e2e43 100644 --- a/mobile/rpc-foundation/goldens/sc-reveal-timeout.json +++ b/mobile/rpc-foundation/goldens/sc-reveal-timeout.json @@ -3,7 +3,7 @@ "family": "session.tab-reveal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5e47c7960b9352753f0751d4ccf5e3ff7fb7b74a63cc4e4df3c3ffdcc6c1e66b", 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 78c353e9e17..8d9358da2e1 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 3af15059d0b..43259c0204c 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 @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json index 7e191d57c75..e93d03b75bc 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit-rejected.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-commit.json b/mobile/rpc-foundation/goldens/sc-review-commit.json index 6862660951f..138f19fbe4d 100644 --- a/mobile/rpc-foundation/goldens/sc-review-commit.json +++ b/mobile/rpc-foundation/goldens/sc-review-commit.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", 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 fde69778827..79c7440a004 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 @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json index 42542b10a0d..3c4f90c59f9 100644 --- a/mobile/rpc-foundation/goldens/sc-review-status-normalized.json +++ b/mobile/rpc-foundation/goldens/sc-review-status-normalized.json @@ -3,7 +3,7 @@ "family": "git.review-preparation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c582ce355b0704b38d7e84cbce352630096c91231bf09aecd1f6dbb409fed17f", diff --git a/mobile/rpc-foundation/goldens/schedules-b3.json b/mobile/rpc-foundation/goldens/schedules-b3.json index 386dc102c49..22804fede52 100644 --- a/mobile/rpc-foundation/goldens/schedules-b3.json +++ b/mobile/rpc-foundation/goldens/schedules-b3.json @@ -3,7 +3,7 @@ "family": "linear-detail-barrier", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", 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 57254df3d70..af728444215 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 1f6b17e152f..c920fabd939 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 7e8aa00cafd..1b95c26e609 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 c933995a406..a2b54e45657 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 4440e9c54e9..d220a0d5d71 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 b62ed64e741..fbee0b8b473 100644 --- a/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/session-browser-tab-created.json b/mobile/rpc-foundation/goldens/session-browser-tab-created.json index 023e9e3bdd2..cd208d04e14 100644 --- a/mobile/rpc-foundation/goldens/session-browser-tab-created.json +++ b/mobile/rpc-foundation/goldens/session-browser-tab-created.json @@ -3,7 +3,7 @@ "family": "session.browser-tab-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-refused.json b/mobile/rpc-foundation/goldens/session-create-browser-refused.json index 5f5005a35d7..b493cbe3b51 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-refused.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-browser-tab.json b/mobile/rpc-foundation/goldens/session-create-browser-tab.json index 746f35db033..b5a3eb30614 100644 --- a/mobile/rpc-foundation/goldens/session-create-browser-tab.json +++ b/mobile/rpc-foundation/goldens/session-create-browser-tab.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 ecf0a19114a..f758d48f2cf 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-create-markdown-note.json b/mobile/rpc-foundation/goldens/session-create-markdown-note.json index 393fbfb7064..bb8f96bc950 100644 --- a/mobile/rpc-foundation/goldens/session-create-markdown-note.json +++ b/mobile/rpc-foundation/goldens/session-create-markdown-note.json @@ -3,7 +3,7 @@ "family": "session.content-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 6d57100e936..67c96e0e2aa 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 @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", 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 18cd4013128..0b133ccfa95 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 @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json index 0dab9b0ce79..99cb58af8a1 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-refused.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-refused.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", 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 172a0286cff..7ff9bd442e7 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", 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 242fad093c0..5d3ad4f5a5b 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 @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", 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 578d8d9c090..38a47b44a8e 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", 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 d2fc9d0c5c8..47ced3dad4b 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 @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", 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 744f5d59d66..0a369faee45 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json @@ -3,7 +3,7 @@ "family": "session.create-terminal", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fa7d9fd6428e89282f08e04fefba4289000eb3aed1462489a2f11efed374382c", 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 bc7bfc94a31..662eaa4289c 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json index 0cefbf6dca7..ceababf7aee 100644 --- a/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json +++ b/mobile/rpc-foundation/goldens/session-diff-notes-loaded.json @@ -3,7 +3,7 @@ "family": "session.diff-notes", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-file-tab-read.json b/mobile/rpc-foundation/goldens/session-file-tab-read.json index 83f65476848..d994300f650 100644 --- a/mobile/rpc-foundation/goldens/session-file-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-file-tab-read.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json index 06b5adad1c1..bc585366f51 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-read.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json index fb68dd3bcbc..a58b982b032 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-disk-served.json +++ b/mobile/rpc-foundation/goldens/session-markdown-disk-served.json @@ -3,7 +3,7 @@ "family": "session.markdown-disk-fallback", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json index 294c1382db8..05ef486efd3 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json +++ b/mobile/rpc-foundation/goldens/session-markdown-save-conflict.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", diff --git a/mobile/rpc-foundation/goldens/session-markdown-saved.json b/mobile/rpc-foundation/goldens/session-markdown-saved.json index 8bcb31c6e9c..2ca7429d687 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-saved.json +++ b/mobile/rpc-foundation/goldens/session-markdown-saved.json @@ -3,7 +3,7 @@ "family": "session.markdown-save", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "42334358b5e5966001639653b553f15033f6e201d785107871fe056536f0a5e2", 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 2848f3c2591..8e1d814c42f 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json index 375edc49824..bb8d4d1c3cc 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-read.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-read.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json index c9cb264b619..7fc0d4c2316 100644 --- a/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json +++ b/mobile/rpc-foundation/goldens/session-markdown-tab-refused.json @@ -3,7 +3,7 @@ "family": "session.tab-documents", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 bc124647440..140568a674b 100644 --- a/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json +++ b/mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", 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 df761781bb0..e4ba7d6609e 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 @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", 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 04420749fc4..47c68de8a4e 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 @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", 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 c616693b8b2..b74a116393a 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 @@ -3,7 +3,7 @@ "family": "session.startup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "6b08c394e37fd572cf63a4c11934247d379008f11117aa5af33b2926fbd32d1e", 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 3a119832326..237eb95ba4e 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 @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json index 116766aa3c5..4a08a057eb8 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-refused.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-refused.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", 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 5d27722efff..d7816c487bf 100644 --- a/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json +++ b/mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json @@ -3,7 +3,7 @@ "family": "session.tab-activation", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", 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 c139ddd4898..3729c8a4e0d 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 @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", 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 04e9f418fba..890988141f1 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-session-tab.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json index 00e141a9c9a..d0c0b4e1e54 100644 --- a/mobile/rpc-foundation/goldens/session-tab-close-terminal.json +++ b/mobile/rpc-foundation/goldens/session-tab-close-terminal.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-closed.json b/mobile/rpc-foundation/goldens/session-tab-closed.json index 532937e1a1f..58f7d2c89b5 100644 --- a/mobile/rpc-foundation/goldens/session-tab-closed.json +++ b/mobile/rpc-foundation/goldens/session-tab-closed.json @@ -3,7 +3,7 @@ "family": "session.tab-close-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-rename.json b/mobile/rpc-foundation/goldens/session-tab-rename.json index 28e0b286c1b..25d6dd7ff72 100644 --- a/mobile/rpc-foundation/goldens/session-tab-rename.json +++ b/mobile/rpc-foundation/goldens/session-tab-rename.json @@ -3,7 +3,7 @@ "family": "session.tab-close", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tab-renamed.json b/mobile/rpc-foundation/goldens/session-tab-renamed.json index 8823dfd0293..ed066a87423 100644 --- a/mobile/rpc-foundation/goldens/session-tab-renamed.json +++ b/mobile/rpc-foundation/goldens/session-tab-renamed.json @@ -3,7 +3,7 @@ "family": "session.tab-rename", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "562c2f4ab669e774dd7045ecd9518e483845433fef6034688090f3b056df2815", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json index 40201b61b71..6da5f28240a 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-errored.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-errored.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json index b00802178bb..9329f6d8151 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", diff --git a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json index ab94ed29547..157fefada79 100644 --- a/mobile/rpc-foundation/goldens/session-tabs-health-refused.json +++ b/mobile/rpc-foundation/goldens/session-tabs-health-refused.json @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", 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 2eadd813589..611c202a758 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 @@ -3,7 +3,7 @@ "family": "session.tabs-stream-health", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4a1e81ab3229c8fd10b3ad435568efec11a944e0f02a183f94e4b1f44a7e5de0", 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 adf5fbff712..a9d96292fd2 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 @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", 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 5418f441db7..53323a00850 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 @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", 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 121c6a2f9ab..fc57e97eb1f 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 @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", 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 6de5a5d6d2e..fa0f936bac5 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 @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", 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 1864a5dbd75..267ee68b793 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 @@ -3,7 +3,7 @@ "family": "session.terminal-display-mode", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9e90ad39a8d4257adf30166757a6364c4710dc3ae9f06365f80a3dd8f1c94d89", 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 60fcb870949..dde7b4e2b1d 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", 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 cace4dc8432..1304b6d2d15 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json index 3221a29beba..2466a6acb85 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-merged.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-merged.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json index bf26523ba64..3060f266068 100644 --- a/mobile/rpc-foundation/goldens/session-terminal-list-refused.json +++ b/mobile/rpc-foundation/goldens/session-terminal-list-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-inventory", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "a3daf9a05d05424959baf77feb45cb0c076d77d8c49e877941ed612cc1e5ce95", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json index 6b1c2c84311..ea89099af12 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 6de323b4f41..7b34727382f 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json index b62449ba10d..289cea6a4dd 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 a851964b460..469cc04e8ba 100644 --- a/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.bot-overrides", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-coalesced.json b/mobile/rpc-foundation/goldens/settings-home-coalesced.json index 226a1a20f2d..4bbda1487af 100644 --- a/mobile/rpc-foundation/goldens/settings-home-coalesced.json +++ b/mobile/rpc-foundation/goldens/settings-home-coalesced.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json index ebd04a3356e..6331f10b5b1 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 429c80d4b08..fc844d3e4c5 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 @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json index 1474568ae39..a33e64c910d 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-refused.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-refused.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 715286e8ebe..d226228b33c 100644 --- a/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.home-providers", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json index f9604a5b3bc..0fd8b5c545f 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-refused.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-refused.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json index 4d9ce988867..dd636c43ac7 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-ssh.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", 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 de64e9a41cc..0e85224755f 100644 --- a/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json @@ -3,7 +3,7 @@ "family": "settings-agent-read", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "448cdbd12f4f6a14bb33947bfbbb1837aebeb28979db70c62f2ba2fbb4d89c8f", diff --git a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json index d3481d643dc..284bb8dccb9 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json +++ b/mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json index 8ebbc2753f7..7dc7dafba2d 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json index f048620d0d5..74b9b368ead 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 15dba24c9bb..048e91bc4a8 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 @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json index 3a1a65f8dcb..ab5ab06802b 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 bd538d91102..295f1a89e66 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 6da7fe8d072..762aa330182 100644 --- a/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.repo-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json index 471ab71032e..59f94319994 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 2ee613a3134..238e05b697f 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 @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json index be7838631e7..0d2aea110fe 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 aa99242acec..b493d8301c2 100644 --- a/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.resume-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json index 3fcec8c482f..f7402150df1 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 4f0f96f0e41..675935e6a07 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 @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json index 937baf88e37..6f8b8978654 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-refused.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 b782fb5e591..6f16b412587 100644 --- a/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.task-hydration", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 0c438fc92df..d1e31a418e8 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", 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 d55e85421d2..6f6ad5a0181 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 @@ -3,7 +3,7 @@ "family": "settings.task-workspace-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json index 492651b81ff..fe964a2851b 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json index 188aa5e90aa..a4cf15ec20d 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-refused.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", 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 e6291280731..bf1825fc881 100644 --- a/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.task-workspace", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-task-write.json b/mobile/rpc-foundation/goldens/settings-task-write.json index 8a6c1c8b10f..16b50f96b15 100644 --- a/mobile/rpc-foundation/goldens/settings-task-write.json +++ b/mobile/rpc-foundation/goldens/settings-task-write.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json index f799c1c49ac..6d9360e04cb 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 c8ff050f3f3..5a194a928fc 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 @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json index 61d83e93fcc..66dfc1b18a6 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-refused.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", 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 21613db1119..a4355d8caed 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.workspace-context", "namedDeltas": ["new-workspace-runtime-context-null-results-degrade-to-absent"], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "fc73116d95985606fe32f22d3f14f434a6d5cd49219416c9fa0f47f971fa6ff0", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json index 63e4a0a9a7e..cceb0745c9b 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json index f10d21c18bf..3de0a24d855 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", 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 b3bde4982df..0e4952a9c40 100644 --- a/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json +++ b/mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json @@ -3,7 +3,7 @@ "family": "settings.workspace-submit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c2eed306311a844cd6f2f84b6513c0a1182f86a5e3cace434385b8287c80d7c5", diff --git a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json index 22f6bb82c19..9dc8018eb3a 100644 --- a/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json +++ b/mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json @@ -3,7 +3,7 @@ "family": "speech.dictation-chunk", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json index c3510db9168..5829932c84a 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 92489c0eacd..c357fd080b1 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json index 5073763f67c..ff341a979c2 100644 --- a/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json +++ b/mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json @@ -3,7 +3,7 @@ "family": "speech.dictation-start", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json index 89a63fad74a..33dce7408fe 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json index ae60401c885..bbdd2636205 100644 --- a/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json +++ b/mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json @@ -3,7 +3,7 @@ "family": "speech.dictation-session", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 e3d2438bb88..e074e2b00fb 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 @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json index a8377b0a20a..ad2c2b29997 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 993bb15fa76..f9ff05f9bfa 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", 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 3a93947f175..a861cd3df34 100644 --- a/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json +++ b/mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json @@ -3,7 +3,7 @@ "family": "speech.setup-sheet", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "5d978690e3626892b83bdba64537f9b1e1bb66c2060a00517ceead042a6d2334", diff --git a/mobile/rpc-foundation/goldens/structured-agent-session-created.json b/mobile/rpc-foundation/goldens/structured-agent-session-created.json index 0cbd771d2d9..c6ee43ea489 100644 --- a/mobile/rpc-foundation/goldens/structured-agent-session-created.json +++ b/mobile/rpc-foundation/goldens/structured-agent-session-created.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-created.json b/mobile/rpc-foundation/goldens/structured-launch-created.json index 90d70038ec3..fee02228fc6 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-created.json +++ b/mobile/rpc-foundation/goldens/structured-launch-created.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json index 1e71a87e696..a8ee592e54e 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json +++ b/mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", 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 46b2de730fa..682e73782d9 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json +++ b/mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json index 6e8d7887fc3..11a1ae08e72 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-support-refused.json +++ b/mobile/rpc-foundation/goldens/structured-launch-support-refused.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json index de2a78d32bb..1d2957a2394 100644 --- a/mobile/rpc-foundation/goldens/structured-launch-unsupported.json +++ b/mobile/rpc-foundation/goldens/structured-launch-unsupported.json @@ -3,7 +3,7 @@ "family": "agentSession.structured-launch", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d340697a64a198066b1037a550afb9a0de7507246a545901d2fc0a23407f38d6", diff --git a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json index 19bcbd46f91..f8ef9c417d6 100644 --- a/mobile/rpc-foundation/goldens/tasks-route-repo-list.json +++ b/mobile/rpc-foundation/goldens/tasks-route-repo-list.json @@ -3,7 +3,7 @@ "family": "tasks.route-repo-list", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "23f974df8b57c723069605de1db80c339ead286b18aae38e67fce03ea50c5180", 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 01688f75af6..3f3a88e9bae 100644 --- a/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json +++ b/mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json @@ -3,7 +3,7 @@ "family": "session.terminal-gesture-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "9d119d5ec320e2538105d6ff673b9f4b8e3decbe46527947489dffbcf2ac0472", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json index 58cea0d2054..10cd3e65573 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json index 863e9b841de..7ffb62337a0 100644 --- a/mobile/rpc-foundation/goldens/terminal-input-send-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-input-send-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json index 771f682039a..ca673c6d596 100644 --- a/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-live-input-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-input-send", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json index 47bb77f8481..e8fd3b767c4 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-accepted.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-paste-refused.json b/mobile/rpc-foundation/goldens/terminal-paste-refused.json index 4064a4633a2..f488db77f38 100644 --- a/mobile/rpc-foundation/goldens/terminal-paste-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-paste-refused.json @@ -3,7 +3,7 @@ "family": "session.terminal-paste", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json index e15624ff3bf..19d2effe75d 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json @@ -3,10 +3,10 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "bfa1d5f83b4112d3cce6a19e9dc27daf9281ed99745bc01bd19226dd7b268c71", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json index 3077fb26458..d7676e22d1c 100644 --- a/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json +++ b/mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json @@ -3,10 +3,10 @@ "family": "terminal.query-reply", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "ad03596f31eeccc5e9eb7e5061b1af705f9af249f76377c8f5ce1a9db257770f", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json index fc9bbc698d8..9946ad0e68d 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-refused.json @@ -3,10 +3,10 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "76fc0c1499ec48a67428f35eba705f68147d2ff2c344b67aa0c9d60ed999f173", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json index cbf95b31627..3c20eb9d6a9 100644 --- a/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json +++ b/mobile/rpc-foundation/goldens/terminal-raw-input-reported.json @@ -3,10 +3,10 @@ "family": "terminal.raw-input", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "579077efd3a4652a6930af3f6690138c20536270cbcd7274246047d2e322199f", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json index 7b44d646c84..e3fab46651a 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json @@ -3,10 +3,10 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "c2236257032fab72ebb007391313eec4e5f0a1bdb4fbcbd907117f9914ffafc6", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json index 01b71dfff14..58e97af466f 100644 --- a/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json +++ b/mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json @@ -3,10 +3,10 @@ "family": "terminal.takeover-report", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "8ad436ca6c3de8b0b3148326337acce18a8a4cf3c514acad1a4530a001f28c75", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json index 26e3a4753a5..174eef0fa8f 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json @@ -3,10 +3,10 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "f966c2ef2e747a5231f423147ddaf38458cb08c719434b274016a5e4abc10771", "platform": "darwin", "scenarioVersion": 1, 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 33e3ca46682..8ea1c81bd6b 100644 --- a/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json +++ b/mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json @@ -3,10 +3,10 @@ "family": "terminal.viewport-refit", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", - "adapterSha256": "7588d30f33a8bb846c48f160aa9a4a8138176662bb2fb6be7bbdf352f553d05f", + "adapterSha256": "42e1fe434b591ab5a945b6b366bbb20b567085d58bade63413f2251e8202b255", "scenarioSha256": "88961a369cb7a7fa92708bb83aa7f818e904018e8cfcedc2f50ef9a0058c8b9d", "platform": "darwin", "scenarioVersion": 1, diff --git a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json index 1ec22f27a7c..b60492b1439 100644 --- a/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json +++ b/mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json @@ -3,7 +3,7 @@ "family": "session.worktree-connection", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "e7e3718f685e3713cf1b8209d59d618741f892bd286853b79bb456c59cec8d86", diff --git a/mobile/rpc-foundation/goldens/tk-create-github.json b/mobile/rpc-foundation/goldens/tk-create-github.json index a726a46c4f6..9edbf4c7c5b 100644 --- a/mobile/rpc-foundation/goldens/tk-create-github.json +++ b/mobile/rpc-foundation/goldens/tk-create-github.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-create-gitlab.json b/mobile/rpc-foundation/goldens/tk-create-gitlab.json index f4f9c9afa2b..9ac18768f93 100644 --- a/mobile/rpc-foundation/goldens/tk-create-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-create-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-create-linear.json b/mobile/rpc-foundation/goldens/tk-create-linear.json index 20b8040d9ed..23757a18f58 100644 --- a/mobile/rpc-foundation/goldens/tk-create-linear.json +++ b/mobile/rpc-foundation/goldens/tk-create-linear.json @@ -3,7 +3,7 @@ "family": "tasks.task-create-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-item-checks-files.json b/mobile/rpc-foundation/goldens/tk-item-checks-files.json index 4cabc430fff..15de8ca9770 100644 --- a/mobile/rpc-foundation/goldens/tk-item-checks-files.json +++ b/mobile/rpc-foundation/goldens/tk-item-checks-files.json @@ -3,7 +3,7 @@ "family": "tasks.item-checks-files", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-github.json b/mobile/rpc-foundation/goldens/tk-item-comment-github.json index 0a55ca31202..37c60805918 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 40e605fe433..3ba00962b59 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json index 14f5ead074a..d2149f2bbb8 100644 --- a/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-comment-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 4f23edc6f67..ec05baaebff 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-github.json b/mobile/rpc-foundation/goldens/tk-item-detail-github.json index 51d4ec36f3f..b9c0b5cf4d9 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", 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 e2e39f7c74f..3a85e9dfb3b 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json index 2023b9c80bc..85466d2d9a5 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json index 2f3ae90a849..4104e9807b0 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-linear.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-linear.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c0ef16b959002e4a3c5347114a0844b95670e274ef010d910b6671ac5f49e783", diff --git a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json index cc85399092f..b7d3c3548a8 100644 --- a/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json +++ b/mobile/rpc-foundation/goldens/tk-item-detail-metadata.json @@ -3,7 +3,7 @@ "family": "tasks.item-detail-metadata", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json index 2c31e18c383..f0f1cee85a6 100644 --- a/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-merge-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json index 82b72617e49..5baef64f2e6 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", 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 814c8263b05..084b033b396 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json index 44142a90a86..ce49d95e747 100644 --- a/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-metadata-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json index e210a5ead08..0b84be2a477 100644 --- a/mobile/rpc-foundation/goldens/tk-item-reply-merge.json +++ b/mobile/rpc-foundation/goldens/tk-item-reply-merge.json @@ -3,7 +3,7 @@ "family": "tasks.item-reply-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", diff --git a/mobile/rpc-foundation/goldens/tk-item-review-github.json b/mobile/rpc-foundation/goldens/tk-item-review-github.json index e41d0e3f547..4f77e5964ab 100644 --- a/mobile/rpc-foundation/goldens/tk-item-review-github.json +++ b/mobile/rpc-foundation/goldens/tk-item-review-github.json @@ -3,7 +3,7 @@ "family": "tasks.item-review-github", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8f68885d57a9aa76d80ba0ee29a95bdbaa98cef29c79c68ce75d67202cde7bfe", 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 ba7d9ac3817..fd4bf42ab43 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab-mr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json index d6dd3ba15f4..f1f85970c98 100644 --- a/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json +++ b/mobile/rpc-foundation/goldens/tk-item-status-gitlab.json @@ -3,7 +3,7 @@ "family": "tasks.item-status-gitlab", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "8c4218bfb2af227da5386f29989cec438f2c6187f39ce1c06859e136ea920bfa", diff --git a/mobile/rpc-foundation/goldens/tk-linear-connect.json b/mobile/rpc-foundation/goldens/tk-linear-connect.json index bfc0ed83f31..74b99af9a03 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-connect.json +++ b/mobile/rpc-foundation/goldens/tk-linear-connect.json @@ -3,7 +3,7 @@ "family": "tasks.linear-connect", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-linear-item.json b/mobile/rpc-foundation/goldens/tk-linear-item.json index 08f7b0f1356..ed7a2a596ab 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-item.json +++ b/mobile/rpc-foundation/goldens/tk-linear-item.json @@ -3,7 +3,7 @@ "family": "tasks.linear-item", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "97cfbcd82778ed6517ca2d10b2f3ad5a8d366e380d7846c1e89d5a5baf17e739", diff --git a/mobile/rpc-foundation/goldens/tk-linear-team-context.json b/mobile/rpc-foundation/goldens/tk-linear-team-context.json index 6dbb8334cc3..a6770bfc5ac 100644 --- a/mobile/rpc-foundation/goldens/tk-linear-team-context.json +++ b/mobile/rpc-foundation/goldens/tk-linear-team-context.json @@ -3,7 +3,7 @@ "family": "tasks.linear-team-context", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "58ea1553e04017c993aea4753aace41ee664705a3fdb3b18569c5a9d7968cf06", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json index 64059fab834..37eff4e2533 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-items.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-items", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json index 210cfd07a12..2cbfdc71345 100644 --- a/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json +++ b/mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-gitlab-todos", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-list-linear.json b/mobile/rpc-foundation/goldens/tk-list-linear.json index 96a532789fc..2cdee6f7f84 100644 --- a/mobile/rpc-foundation/goldens/tk-list-linear.json +++ b/mobile/rpc-foundation/goldens/tk-list-linear.json @@ -3,7 +3,7 @@ "family": "tasks.task-list-linear", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", diff --git a/mobile/rpc-foundation/goldens/tk-project-board-load.json b/mobile/rpc-foundation/goldens/tk-project-board-load.json index 22a42461470..d495876e45a 100644 --- a/mobile/rpc-foundation/goldens/tk-project-board-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-board-load.json @@ -3,7 +3,7 @@ "family": "tasks.project-board-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", diff --git a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json index 961d20cf95a..13bea2fa111 100644 --- a/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json +++ b/mobile/rpc-foundation/goldens/tk-project-repo-slugs.json @@ -3,7 +3,7 @@ "family": "tasks.project-repo-slugs", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "c4272385ed3b0de4feab38de9e4f6363ecd6317fdd4de47f76a98eb18abaf371", 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 bf33769920e..ad2609c4a53 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-issue", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", 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 afb5f3acb3d..cc93506baee 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-comments-pr", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-detail.json b/mobile/rpc-foundation/goldens/tk-project-row-detail.json index 3c65285510b..f81ea5085c2 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-detail.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-detail.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-detail", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-fields.json b/mobile/rpc-foundation/goldens/tk-project-row-fields.json index 2a2cab6daf8..ecf900b7b9b 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-fields.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-fields.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-fields", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", 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 2fc2ea87383..50770208962 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-files-merge.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-files-merge", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "b228732762828412ad3d9eec3ece00a897d866046e37044322c3911758d6e0a9", 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 500429e320a..b22f912af37 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-metadata-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "f8f6e5d500f959b9b15c5498885a05422747880b6aef4ad795bc3064ebbacea6", 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 4ec4863bda4..065975ecbd2 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-review-checks.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-review-checks", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "370aeaee59978071ccb821da13c9e6114936c168947b608539cdb80d40cc9889", diff --git a/mobile/rpc-foundation/goldens/tk-project-row-threads.json b/mobile/rpc-foundation/goldens/tk-project-row-threads.json index 23bdef3a306..3f77cab43cf 100644 --- a/mobile/rpc-foundation/goldens/tk-project-row-threads.json +++ b/mobile/rpc-foundation/goldens/tk-project-row-threads.json @@ -3,7 +3,7 @@ "family": "tasks.project-row-threads", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "55058202df36c8b951510215936e496ea88d3d71a6690090a13c52deb13e34e1", diff --git a/mobile/rpc-foundation/goldens/tk-provider-load.json b/mobile/rpc-foundation/goldens/tk-provider-load.json index e88a216086d..ad1489bb3ad 100644 --- a/mobile/rpc-foundation/goldens/tk-provider-load.json +++ b/mobile/rpc-foundation/goldens/tk-provider-load.json @@ -3,7 +3,7 @@ "family": "tasks.provider-load", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "07a15e900363faa25bc364f5bc1c330f96798abfeaed35210428c2ac558586b8", 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 54bcd548b6e..fc3e688a0d5 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 @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 d3c7c22440b..c5f1d44911e 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 @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json index 0090c0cba62..4283db94a47 100644 --- a/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json +++ b/mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 ae52069f08e..ebb9d1fb932 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 @@ -3,7 +3,7 @@ "family": "transport.capability-probe", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 68cee611928..9aa6562f39f 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 @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 88bdfee6cd3..40132e9e1ec 100644 --- a/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json +++ b/mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 ad65205ce63..99d4963780d 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 @@ -3,7 +3,7 @@ "family": "transport.host-status-gates", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 e1f6c3668e4..3d655a8f42b 100644 --- a/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json +++ b/mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 ef39cd9db68..92a2b4eecd7 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 @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 ebb1c60ac0c..2feb84554d1 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 @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", 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 75ab705edc8..957af87f160 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 @@ -3,7 +3,7 @@ "family": "transport.pairing-race", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "34b382b13fe75e8ef4002325287c95b3c4db62eeaaf3762af7fbaf6f836c2fa1", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json index 92e5862a0de..556c4b649c2 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-advertised.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json index 426010a0005..8573b4902c7 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json index 6d525a05c69..12ac26904e2 100644 --- a/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json +++ b/mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json @@ -3,7 +3,7 @@ "family": "worktree.runtime-capabilities", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 01a9a5577ab..35bcbfa6500 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json @@ -3,11 +3,11 @@ "family": "worktree.agent-launch-create", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", - "scenarioSha256": "d193972745f28b4a8da9b19176ba9825b7b6eb78f28802c6f6eea99e155fdfe6", + "scenarioSha256": "85595f5fdde16441d22bf07c14c65edda6cb90019d036aae2c5031d19664e66f", "platform": "darwin", "scenarioVersion": 1, "projectionVersion": 2, 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 d398ba67dd7..0e109a4c28c 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 @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 b18f96b3a00..5174ee540e1 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 @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 3953f544385..1937c32ae84 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 @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-create-retry-created.json b/mobile/rpc-foundation/goldens/tw-create-retry-created.json index 93cf39b3cca..b419ccb9433 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-created.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-created.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 2688ddeca28..addd1959fa9 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 16590290455..38dfeaf46a9 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 9d28ac5e786..6516a11579d 100644 --- a/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json +++ b/mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json @@ -3,7 +3,7 @@ "family": "worktree.create-retry", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json index 76953af095b..ee87403dcdd 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 39f0f001989..ca34001ac4f 100644 --- a/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json +++ b/mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json @@ -3,7 +3,7 @@ "family": "worktree.hosted-base", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", diff --git a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json index 6689f2bbbb0..74e3431b0c5 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 367f5ffbcdc..382f8b47a98 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 f917e9f58fa..5cf0fb3c664 100644 --- a/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json +++ b/mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json @@ -3,7 +3,7 @@ "family": "tasks.paste-lookup", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 feb1f1fcdcd..07943ec8864 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 2a4d94f99b2..e9c9c89dd10 100644 --- a/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json +++ b/mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json @@ -3,7 +3,7 @@ "family": "worktree.setup-hook-trust", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 ec8cc0c3ed4..eb5eec8394d 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 62c836bdb6e..c5a55c67575 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 @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 4df4df34170..a1b1cdd0247 100644 --- a/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json +++ b/mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json @@ -3,7 +3,7 @@ "family": "tasks.smart-source-search", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "52a76b7a830b32287bce14abbe1b9d9ac70e71eafe5b4c6801c2eb14a4150125", 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 c7d527bbb11..6184077cf59 100644 --- a/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json +++ b/mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json @@ -3,7 +3,7 @@ "family": "settings-best-effort", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab", 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 0f0de6b95f3..0b246c45426 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json index d5461af71c6..ada7e73f232 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-source-presets.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-source", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 b104a2455c9..aa5fb681f76 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json index 544c156a0b2..a9a0adfbd30 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-sparse", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 a0b373b47ba..f77e63d59d4 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json index d686144493e..dbdf3aa282d 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 25be3c5da35..42ab1548b5c 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh-local", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", 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 b58c3a9d770..31f60e7b635 100644 --- a/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json +++ b/mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json @@ -3,7 +3,7 @@ "family": "tasks.workspace-ssh", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e567302ac8acffcfd602c9b323ecf8b5b7c0c4692bda1a4c881011a91d98979", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json index 3ae6f12c913..14d129b25f2 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json index b2970073a31..79b9fd77f2a 100644 --- a/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json +++ b/mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json @@ -3,7 +3,7 @@ "family": "worktree.catalog-snapshot", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-home-catalog.json b/mobile/rpc-foundation/goldens/worktree-home-catalog.json index f22373adb55..596cae592e7 100644 --- a/mobile/rpc-foundation/goldens/worktree-home-catalog.json +++ b/mobile/rpc-foundation/goldens/worktree-home-catalog.json @@ -3,7 +3,7 @@ "family": "worktree.home-catalog", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/goldens/worktree-retired-names.json b/mobile/rpc-foundation/goldens/worktree-retired-names.json index 82bbadcc79a..4d24bf3cb44 100644 --- a/mobile/rpc-foundation/goldens/worktree-retired-names.json +++ b/mobile/rpc-foundation/goldens/worktree-retired-names.json @@ -3,7 +3,7 @@ "family": "worktree.retired-names", "namedDeltas": [], "runnerVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb", "recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af", "adapterSha256": "4e942ddfbaa0ba6bfc2993969276987f6528ac53765d125e99a830e261f93a8e", diff --git a/mobile/rpc-foundation/pilot-scenarios.json b/mobile/rpc-foundation/pilot-scenarios.json index ba0616def2d..6e8f716a83b 100644 --- a/mobile/rpc-foundation/pilot-scenarios.json +++ b/mobile/rpc-foundation/pilot-scenarios.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "baseline": "44b829c8178af0e08ba953aa45e5c4c907164bf5", + "baseline": "2077956254c707d904c066ca890dc25f3671fb70", "scenarios": [ { "id": "b1", diff --git a/mobile/src/components/MobileDiffReviewDrawers.tsx b/mobile/src/components/MobileDiffReviewDrawers.tsx index 1fcf17a9fcb..828a09beaec 100644 --- a/mobile/src/components/MobileDiffReviewDrawers.tsx +++ b/mobile/src/components/MobileDiffReviewDrawers.tsx @@ -1,8 +1,7 @@ import { useMemo } from 'react' -import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native' +import { KeyboardAvoidingView, Pressable, Text, TextInput, View } from 'react-native' import { Check, Copy, FileText, Plus, Send, Trash2, X } from 'lucide-react-native' import type { DiffComment } from '../../../src/shared/diff-comment-types' -import { useKeyboardAvoidingPadding } from '../platform/keyboard-occlusion' import { colors } from '../theme/mobile-theme' import type { ActionSheetAction } from './ActionSheetModal' import { ActionSheetContent } from './ActionSheetModal' @@ -17,6 +16,7 @@ import { import { reviewSheetKey, type ReviewSheet } from '../session/mobile-diff-review-sheets' import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller' import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles' +import { hostOs } from '../platform/host-os' type Props = { controller: ReturnType @@ -196,15 +196,8 @@ function sendSheetMessage( } function NoteComposerContent({ controller, composer }: Props & { composer: ComposerState }) { - // Zero on a phone, where `KeyboardAvoidingView` above already moved this; the page's own - // keyboard measurement where it cannot, because that view is driven by events RN Web never - // sends. Padding rather than a second avoiding view: the drawer owns the position. - const keyboardPadding = useKeyboardAvoidingPadding() return ( - 0 ? { paddingBottom: keyboardPadding } : undefined} - > + diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index cadaf186fb2..19cf9d93993 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -178,7 +178,6 @@ const styles = StyleSheet.create({ metaText: { flex: 1, fontSize: 12, color: colors.textSecondary }, worktreeMetaText: { marginTop: 2, - marginLeft: spacing.xl, fontSize: 12, color: colors.textMuted }, diff --git a/mobile/src/components/ProtocolBlockScreen.test.ts b/mobile/src/components/ProtocolBlockScreen.test.ts index a69b52566e3..c0dd920293d 100644 --- a/mobile/src/components/ProtocolBlockScreen.test.ts +++ b/mobile/src/components/ProtocolBlockScreen.test.ts @@ -151,6 +151,19 @@ describe('ProtocolBlockScreen', () => { expect(primaryActionUrl()).toBe(RELEASES_URL) }) + it('sends a desktop whose page is older than this shell to the desktop update', () => { + const output = render({ + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'desktop', + pageVersion: 0, + requiredPageVersion: 1 + }) + expect(output).toContain('Update Orca on your computer') + expect(output).toContain('This paired desktop app is too old for your current Orca Mobile app') + expect(primaryActionUrl()).toBe(RELEASES_URL) + }) + it('routes an Android bundle wall to GitHub Releases, not a store that has no listing', () => { nativeTestState.platform.OS = 'android' const output = render({ diff --git a/mobile/src/components/StatusDot.tsx b/mobile/src/components/StatusDot.tsx index f9514b4e0bf..a1e974f1cd2 100644 --- a/mobile/src/components/StatusDot.tsx +++ b/mobile/src/components/StatusDot.tsx @@ -42,7 +42,6 @@ const styles = StyleSheet.create({ dot: { width: 8, height: 8, - borderRadius: 4, - marginRight: 8 + borderRadius: 4 } }) diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index 473b557096e..8d0a6d474f7 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -18,6 +18,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial ({ NotificationFeedbackType: {} })) vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) -vi.mock('../platform/keyboard-occlusion', () => ({ useKeyboardAvoidingPadding: () => 0 })) vi.mock('./mobile-diff-review-screen-styles', () => ({ mobileDiffReviewStyles: new Proxy({}, { get: () => ({}) }) })) diff --git a/mobile/src/components/mounted-bottom-drawer-page-keyboard.web.test.tsx b/mobile/src/components/mounted-bottom-drawer-page-keyboard.web.test.tsx index a741f5c864d..d7d0c67a0cb 100644 --- a/mobile/src/components/mounted-bottom-drawer-page-keyboard.web.test.tsx +++ b/mobile/src/components/mounted-bottom-drawer-page-keyboard.web.test.tsx @@ -69,6 +69,7 @@ vi.mock('react-native-reanimated', () => ({ })) import { MountedBottomDrawer } from './mounted-bottom-drawer' +import { publishShellKeyboardSource } from '../platform/keyboard-occlusion.web' const RESTING_HEIGHT = 900 const IME_HEIGHT = 300 @@ -106,15 +107,36 @@ describe('a fill-mode sheet on the page', () => { act(() => renderer.unmount()) }) - it('does not lift over a keyboard the shell already shortened the WebView for', () => { - setWindowHeight(RESTING_HEIGHT) - const renderer = renderFillSheet() - act(() => setWindowHeight(RESTING_HEIGHT - IME_HEIGHT)) - // The shortened window is the whole avoidance: the sheet's height follows it and nothing lifts. - expect(sheetStyle(renderer)).toMatchObject({ - marginBottom: 0, - height: RESTING_HEIGHT - IME_HEIGHT - 24 - 16 + it('sits on the keyboard the shell says covers the page, as it does natively', () => { + let height = 0 + const listeners = new Set<(next: number) => void>() + publishShellKeyboardSource({ + read: () => height, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + } }) - act(() => renderer.unmount()) + try { + setWindowHeight(RESTING_HEIGHT) + const renderer = renderFillSheet() + act(() => { + height = IME_HEIGHT + listeners.forEach((listener) => listener(IME_HEIGHT)) + }) + expect(sheetStyle(renderer)).toMatchObject({ + marginBottom: IME_HEIGHT, + height: RESTING_HEIGHT - IME_HEIGHT - 24 - 16 + }) + act(() => { + height = 0 + listeners.forEach((listener) => listener(0)) + }) + expect(sheetStyle(renderer).marginBottom).toBe(0) + act(() => renderer.unmount()) + expect(listeners.size).toBe(0) + } finally { + publishShellKeyboardSource(null) + } }) }) diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx index d42fce7912a..764a3c5437e 100644 --- a/mobile/src/components/mounted-bottom-drawer.tsx +++ b/mobile/src/components/mounted-bottom-drawer.tsx @@ -1,13 +1,5 @@ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' -import { - View, - Pressable, - useWindowDimensions, - ScrollView, - Keyboard, - Modal, - Platform -} from 'react-native' +import { View, Pressable, useWindowDimensions, ScrollView, Keyboard, Modal } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' import Animated, { @@ -29,6 +21,7 @@ import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host' import { useResponsiveLayout } from '../layout/responsive-layout' import { useBackClaim } from '../navigation/use-back-claim' import { currentSoftKeyboardHeight, subscribeSoftKeyboard } from '../platform/keyboard-occlusion' +import { hostOs } from '../platform/host-os' const DISMISS_THRESHOLD = 80 const SPRING_CONFIG = { damping: 28, stiffness: 400 } @@ -131,8 +124,7 @@ export function MountedBottomDrawer({ // Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable // inside Modal (iOS ignores KAV; Android needs adjustNothing for // useAnimatedKeyboard). The keyboard seam's events work on both platforms - // and give the exact height; inside the shell's page none fire, because - // the shell shortens the WebView above the IME. + // and give the exact height; inside the shell's page they are the shell's. useEffect(() => { // Pinned-under sheets stay visible for size but must not ride the keyboard — // only the top interactive sheet owns inset/lift. @@ -147,7 +139,7 @@ export function MountedBottomDrawer({ keyboardHeight, bottomInset: insets.bottom, fillAvailable, - platform: Platform.OS + platform: hostOs() }) setKeyboardInset(inset) if (duration > 0) { diff --git a/mobile/src/host-screen/host-screen-primary-styles.ts b/mobile/src/host-screen/host-screen-primary-styles.ts index 1f13dd17b16..e262af906b5 100644 --- a/mobile/src/host-screen/host-screen-primary-styles.ts +++ b/mobile/src/host-screen/host-screen-primary-styles.ts @@ -39,7 +39,7 @@ export const hostScreenPrimaryStyles = StyleSheet.create({ minWidth: 0, marginRight: spacing.md }, - hostIdentityLine: { flexDirection: 'row', alignItems: 'center', minWidth: 0 }, + hostIdentityLine: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, minWidth: 0 }, hostPlatformText: { marginLeft: 16, color: colors.textSecondary, diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx index b41c67a2d71..a93f503bd63 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -39,14 +39,13 @@ import { textOf, updateScreen as reRenderScreen } from './mobile-web-shell-screen-test-harness' -import { BRIDGE_BACK_CLAIM_NOTIFY, BRIDGE_BACK_FRAME } from './bridge/bridge-page-back' +import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge/bridge-page-back' import { BRIDGE_PAGE_PAINTED } from './bridge/bridge-page-painted' import { BRIDGE_FAULT_GRANT, BRIDGE_NAVIGATE_BACK_NOTIFY, readBridgeHostMessage } from './bridge/bridge-envelope' -import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge/bridge-route-update' import { MobileWebShellScreen } from './MobileWebShellScreen' import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' import type { ReactTestInstance, ReactTestRenderer } from 'react-test-renderer' @@ -227,7 +226,7 @@ describe('the hybrid shell screen', () => { tree: ReactTestRenderer initRoutes: () => (Record | undefined)[] move: (next: Record) => Promise - ready: (accepts?: readonly string[]) => Promise + ready: () => Promise }> { const element = (next: Record) => createElement(MobileWebShellScreen, { @@ -259,10 +258,10 @@ describe('the hybrid shell screen', () => { tree.update(element(next)) }) }, - ready: async (accepts = [BRIDGE_ROUTE_UPDATE_ACCEPT]) => { + ready: async () => { await act(async () => { byName(tree, 'ShellViewProbe')[0]?.props.onBridgeMessage({ - nativeEvent: { json: clientFrame({ type: 'ready', accepts }) } + nativeEvent: { json: clientFrame({ type: 'ready' }) } }) }) } @@ -302,18 +301,6 @@ describe('the hybrid shell screen', () => { warned.mockRestore() }) - it('sends no second init to a page that never said it takes one', async () => { - dependencies.client = createFakeRpcClient() - const page = await renderForRoute({ paneKey: '' }) - // A page built before route updates existed declares nothing, and reads a second `init` as a - // replacement: the route still moves, so its next `ready` is answered with the new one. - await page.ready([]) - await page.move({ paneKey: 'pane-1' }) - expect(page.initRoutes()).toEqual([{ paneKey: '' }]) - await page.ready([]) - expect(page.initRoutes()).toEqual([{ paneKey: '' }, { paneKey: 'pane-1' }]) - }) - it('ends that wait on the page asking for a session', async () => { dependencies.client = createFakeRpcClient() const tree = await renderScreen(readyState('session-one')) @@ -434,7 +421,7 @@ describe('the hybrid shell screen', () => { const tree = await renderScreen(readyState('session-one')) await act(async () => { byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({ - nativeEvent: { json: clientFrame({ type: 'ready', accepts: [BRIDGE_BACK_FRAME] }) } + nativeEvent: { json: clientFrame({ type: 'ready' }) } }) byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({ nativeEvent: { @@ -661,25 +648,6 @@ describe('what one case mutates does not reach the next', () => { client: null }) }) - - it('keeps both bar strips off the view for a page that does not pad for them', async () => { - // A page served from an older desktop has no reader for the insets, so the shell reserves - // the strips itself. Edge-to-edge makes the manifest's `adjustResize` inert, so the keyboard - // strip comes off the view too: the page's `visualViewport` reads full height with the IME up. - const tree = await renderScreen(readyState('session-keyboard')) - const root = tree.root.find((node) => node.props.testID === 'mobile-web-shell-ready') - expect(root.props.style[1]).toEqual({ paddingTop: 44, paddingBottom: 8 }) - - await act(async () => { - dependencies.keyboardListeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 } }) - }) - expect(root.props.style[1]).toEqual({ paddingTop: 44, paddingBottom: 336 }) - - await act(async () => { - dependencies.keyboardListeners.get('keyboardWillHide')?.({ endCoordinates: { height: 0 } }) - }) - expect(root.props.style[1]).toEqual({ paddingTop: 44, paddingBottom: 8 }) - }) }) /** @@ -718,17 +686,17 @@ describe('the frame under a page that has not painted', () => { expect(tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-cover')).toEqual([]) }) - it('covers the same box the view gets, which is what the keyboard strip shortens', async () => { + it('covers the same box the view gets, which the keyboard does not shorten', async () => { // Both are children of the padded root: the view is `flex: 1` and the cover is an absolute - // fill, so Yoga lays each of them out against the same content box. The keyboard takes its - // strip off that box, so it takes it off both, and the cover cannot leave a gap the view fills. + // fill, so Yoga lays each of them out against the same content box, and the cover cannot leave + // a gap the view fills. The keyboard covers that box rather than taking a strip off it. dependencies.pageFrame = 'unpainted' const tree = await renderScreen(readyState('session-keyboard-cover')) const root = tree.root.find((node) => node.props.testID === 'mobile-web-shell-ready') await act(async () => { dependencies.keyboardListeners.get('keyboardWillShow')?.({ endCoordinates: { height: 336 } }) }) - expect(root.props.style[1]).toEqual({ paddingTop: 44, paddingBottom: 336 }) + expect(root.props.style[1]).toEqual({ paddingTop: 0 }) const cover = tree.root.find((node) => node.props.testID === 'mobile-web-shell-cover') expect(cover.props.style[0]).toMatchObject({ position: 'absolute', @@ -750,13 +718,10 @@ describe('the frame under a page that has not painted', () => { const probe = byName(tree, 'ShellViewProbe')[0] await act(async () => { probe.props.onBridgeMessage({ - nativeEvent: { json: clientFrame({ type: 'ready', reports: [BRIDGE_PAGE_PAINTED] }) } + nativeEvent: { json: clientFrame({ type: 'ready' }) } }) }) - expect(dependencies.reportPageReady).toHaveBeenCalledWith({ - reports: [BRIDGE_PAGE_PAINTED], - accepts: [] - }) + expect(dependencies.reportPageReady).toHaveBeenCalledTimes(1) await act(async () => { probe.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'notify', name: BRIDGE_PAGE_PAINTED }) } diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index 83d6d172499..88cedd9fd6d 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -1,13 +1,5 @@ import { useEffect, useState, type ReactNode } from 'react' -import { - ActivityIndicator, - Linking, - Platform, - Pressable, - StyleSheet, - Text, - View -} from 'react-native' +import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native' import { useNavigation, useRouter } from 'expo-router' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { @@ -34,7 +26,6 @@ import { playPageHaptic } from './page-haptics' import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge' import type { MobileWebShellRuntime } from './mobile-web-shell-runtime' import { useKeyboardOcclusion } from '../platform/keyboard-occlusion' -import { softwareKeyboardWindowInset } from '../platform/software-keyboard-window-inset' import { useNativeDeviceVerbs } from '../platform/use-native-device-verbs' import { useShellPageBack } from './use-shell-page-back' import { useShellStackPop } from './use-shell-stack-pop' @@ -178,16 +169,6 @@ export function MobileWebShellScreen({ runtime }: MobileWebShellScreenProps) { const insets = useSafeAreaInsets() - // The page cannot see the IME for itself: edge-to-edge makes the manifest's `adjustResize` inert, - // so the window never shrinks and `visualViewport` inside the WebView reads full height with the - // keyboard up — the session route lays its live input row out under the keys. The shell owns the - // window, so it takes the strip off the view and the page lays out in what is left. - const keyboardHeight = useKeyboardOcclusion() - const keyboardInset = softwareKeyboardWindowInset({ - keyboardHeight, - bottomInset: insets.bottom, - platform: Platform.OS - }) const router = useRouter() const navigation = useNavigation() const popShellStack = useShellStackPop() @@ -207,19 +188,18 @@ export function MobileWebShellScreen({ reportPageBackClaim, pageReady, pageFrame, - backClaimed, - pageOwnsSafeArea + backClaimed } = useMobileWebShellSession({ hostId, routePathname: route.pathname, runtime }) // Which mount the notice was dismissed on, not whether it was: a later refusal opens its own // generation under a new session id, so it is not silenced by a tap on the one before it. const [noticeDismissedFor, setNoticeDismissedFor] = useState(null) const noticeShown = updateNotice !== null && state.kind === 'ready' && noticeDismissedFor !== state.sessionId - const pageInsets = pageSafeAreaInsets({ - insets, - keyboardInset, - topCovered: noticeShown - }) + // The page cannot see the IME for itself: edge-to-edge makes `adjustResize` inert and the view's + // IME insets are zeroed, so `visualViewport` never shrinks. The keyboard covers the view like a + // native screen, and the page lifts by the height native screens read. + const keyboardInset = Math.max(0, useKeyboardOcclusion()) + const pageInsets = pageSafeAreaInsets({ insets, topCovered: noticeShown }) const { snapshot, unreadable, readStorage, refreshStorage, writeStorage } = usePageHostSnapshot( hostId, route.pathname @@ -242,6 +222,7 @@ export function MobileWebShellScreen({ hostId, route, safeAreaInsets: pageInsets, + keyboardInset, pageRoutes, pageRouteGrants, routeGrants, @@ -264,12 +245,12 @@ export function MobileWebShellScreen({ // the map as they are made. This re-seats that map on the store afterwards, for the key whose // write never persisted, and it runs on every ask because a document that reloads inside this // mount asks again. - onPageReady: (ready) => { - reportPageReady(ready) + onPageReady: () => { + reportPageReady() void refreshStorage() }, // The one thing that says the page is something to look at. The cover below stays up until it - // lands, for a page that declared it would send one. + // lands. onPagePainted: reportPagePainted, // While this is true the key below belongs to the page, not to the stack this screen sits on. onPageBackClaim: reportPageBackClaim, @@ -323,6 +304,11 @@ export function MobileWebShellScreen({ }, [publishRoute, route]) usePublishedSafeAreaInsets(bridge.publishSafeAreaInsets, pageInsets) + // Every keyboard event, a height change while open included, so the page never holds a stale one. + const publishKeyboardInset = bridge.publishKeyboardInset + useEffect(() => { + publishKeyboardInset(keyboardInset) + }, [publishKeyboardInset, keyboardInset]) // The navigation object rather than the router: what this takes away is this screen's own place // on the stack, which is a screen option, and the router has no member that says it. @@ -372,12 +358,9 @@ export function MobileWebShellScreen({ diff --git a/mobile/src/mobile-web-shell/bridge-host-back.test.ts b/mobile/src/mobile-web-shell/bridge-host-back.test.ts index 7010e85ea8b..d368c74d524 100644 --- a/mobile/src/mobile-web-shell/bridge-host-back.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-back.test.ts @@ -3,8 +3,8 @@ import { clientFrame } from './bridge-host-test-fakes' import { harness } from './bridge-host-test-harness' import { BRIDGE_BACK_CLAIM_NOTIFY, BRIDGE_BACK_FRAME } from './bridge/bridge-page-back' -function ready(accepts?: readonly string[]): string { - return clientFrame(accepts === undefined ? { type: 'ready' } : { type: 'ready', accepts }) +function ready(): string { + return clientFrame({ type: 'ready' }) } function claim(claimed: boolean): string { @@ -21,7 +21,7 @@ function claim(claimed: boolean): string { describe('the claim a host holds on the device Back key', () => { it('carries the page taking the key and letting it go, and says neither twice', () => { const bridge = harness() - bridge.host.receive(ready([BRIDGE_BACK_FRAME])) + bridge.host.receive(ready()) bridge.host.receive(claim(true)) bridge.host.receive(claim(true)) bridge.host.receive(claim(false)) @@ -30,15 +30,15 @@ describe('the claim a host holds on the device Back key', () => { it('drops it on the next document saying ready, which is the one that has to claim again', () => { const bridge = harness() - bridge.host.receive(ready([BRIDGE_BACK_FRAME])) + bridge.host.receive(ready()) bridge.host.receive(claim(true)) - bridge.host.receive(ready([BRIDGE_BACK_FRAME])) + bridge.host.receive(ready()) expect(bridge.backClaims).toEqual([true, false]) }) it('drops it when the page closes, which is the document saying it is going', () => { const bridge = harness() - bridge.host.receive(ready([BRIDGE_BACK_FRAME])) + bridge.host.receive(ready()) bridge.host.receive(claim(true)) bridge.host.receive(clientFrame({ type: 'close' })) expect(bridge.backClaims).toEqual([true, false]) @@ -53,7 +53,7 @@ describe('the claim a host holds on the device Back key', () => { */ it('reports nothing when the host itself ends: the claim belongs to the session', () => { const bridge = harness() - bridge.host.receive(ready([BRIDGE_BACK_FRAME])) + bridge.host.receive(ready()) bridge.host.receive(claim(true)) bridge.host.dispose() expect(bridge.backClaims).toEqual([true]) @@ -61,35 +61,35 @@ describe('the claim a host holds on the device Back key', () => { it('hands what the session established to the host that takes over from it', () => { const bridge = harness() - bridge.host.receive(ready([BRIDGE_BACK_FRAME])) + bridge.host.receive(ready()) bridge.host.receive(claim(true)) bridge.host.dispose() - expect(bridge.host.readSessionBack()).toEqual({ accepts: [BRIDGE_BACK_FRAME], claimed: true }) + expect(bridge.host.readSessionBack()).toEqual({ claimed: true }) }) it('takes that seed without announcing a claim the screen never heard go', () => { const rebuilt = harness({ - sessionBack: { accepts: [BRIDGE_BACK_FRAME], claimed: true }, + sessionBack: { claimed: true }, sessionEstablished: true }) expect(rebuilt.backClaims).toEqual([]) - // And it delivers on it: the page said it takes a press, over a session that is still open. + // And it delivers on it, over a session that is still open. expect(rebuilt.host.sendBack()).toBe(true) expect(rebuilt.last()).toEqual({ v: 1, type: BRIDGE_BACK_FRAME }) }) it('lets the next document reset a carried claim, so the seed is not a latch', () => { const rebuilt = harness({ - sessionBack: { accepts: [BRIDGE_BACK_FRAME], claimed: true }, + sessionBack: { claimed: true }, sessionEstablished: true }) - rebuilt.host.receive(ready([BRIDGE_BACK_FRAME])) + rebuilt.host.receive(ready()) expect(rebuilt.backClaims).toEqual([false]) }) it('says nothing on a document that never claimed, so a drop is always a real one', () => { const bridge = harness() - bridge.host.receive(ready([BRIDGE_BACK_FRAME])) + bridge.host.receive(ready()) bridge.host.receive(clientFrame({ type: 'close' })) bridge.host.dispose() expect(bridge.backClaims).toEqual([]) @@ -110,7 +110,7 @@ describe('the claim a host holds on the device Back key', () => { describe('handing one press to the page', () => { it('posts a back frame to a page that said it takes one', () => { const bridge = harness() - bridge.host.receive(ready([BRIDGE_BACK_FRAME])) + bridge.host.receive(ready()) expect(bridge.host.sendBack()).toBe(true) expect(bridge.last()).toEqual({ v: 1, type: BRIDGE_BACK_FRAME }) }) @@ -120,24 +120,16 @@ describe('handing one press to the page', () => { * one as `unrecognised-message` and drops the whole thing, so the shell must not send it. False * is what leaves the press to the navigator, which is what Back did before this lane. */ - it('sends nothing to a page that never declared it, and says so to the caller', () => { - const bridge = harness() - bridge.host.receive(ready()) - const before = bridge.posted.length - expect(bridge.host.sendBack()).toBe(false) - expect(bridge.posted).toHaveLength(before) - }) - it('sends nothing between documents, when the view belongs to no page', () => { const bridge = harness() - bridge.host.receive(ready([BRIDGE_BACK_FRAME])) + bridge.host.receive(ready()) bridge.host.receive(clientFrame({ type: 'close' })) expect(bridge.host.sendBack()).toBe(false) }) it('sends nothing once the host is disposed', () => { const bridge = harness() - bridge.host.receive(ready([BRIDGE_BACK_FRAME])) + bridge.host.receive(ready()) bridge.host.dispose() expect(bridge.host.sendBack()).toBe(false) }) diff --git a/mobile/src/mobile-web-shell/bridge-host-back.ts b/mobile/src/mobile-web-shell/bridge-host-back.ts index b9cbe108e55..a835be0e354 100644 --- a/mobile/src/mobile-web-shell/bridge-host-back.ts +++ b/mobile/src/mobile-web-shell/bridge-host-back.ts @@ -2,8 +2,7 @@ import { BRIDGE_PROTOCOL_VERSION, type BridgeHostMessage } from './bridge/bridge import { BRIDGE_BACK_FRAME } from './bridge/bridge-page-back' /** - * What one session has established about the device Back key: what its page declared it takes, and - * whether it is holding the key right now. + * What one session has established about the device Back key: whether its page is holding it. * * Carried across a host rebuild rather than relearned, the way `sessionEstablished` already is. A * client swapped under a live page is not a new document — the WebView stays mounted and the page @@ -11,8 +10,6 @@ import { BRIDGE_BACK_FRAME } from './bridge/bridge-page-back' * under an open sheet. */ export type BridgeSessionBack = { - /** What the page's last `ready` said it takes. Empty for a page too old to name the frame. */ - readonly accepts: readonly string[] readonly claimed: boolean } @@ -29,14 +26,12 @@ export type BridgeSessionBack = { * whoever builds the replacement, and reports nothing, because the claim belongs to the session. */ export type BridgeHostBack = { - /** A document has spoken: it takes what it named, and claims nothing until it says otherwise. */ - readonly readReady: (accepts: readonly string[]) => void + /** A document has spoken: it claims nothing until it says otherwise. */ + readonly readReady: () => void /** The page holding Back, or letting it go. */ readonly readClaim: (claimed: boolean) => void - /** - * Posts one press. False when this page never said it takes one — every page older than the - * frame — and the caller then leaves Back to the navigator, which is what it did before this. - */ + /** Posts one press. False when no document can take it, and the caller leaves Back to the + * navigator. */ readonly send: () => boolean /** The document is gone; whatever it claimed goes with it. */ readonly drop: () => void @@ -53,7 +48,6 @@ export function createBridgeHostBack(args: { * a host opening a session of its own, which has established nothing yet. */ established?: BridgeSessionBack }): BridgeHostBack { - let accepts: readonly string[] = args.established?.accepts ?? [] // Seeded without a report: the screen never heard this claim go, because it did not. let claimed = args.established?.claimed ?? false @@ -66,10 +60,7 @@ export function createBridgeHostBack(args: { } return { - readReady: (next) => { - accepts = next - drop() - }, + readReady: drop, readClaim: (next) => { if (next === claimed) { return @@ -78,13 +69,13 @@ export function createBridgeHostBack(args: { args.onClaim(next) }, send: () => { - if (!accepts.includes(BRIDGE_BACK_FRAME) || !args.deliverable()) { + if (!args.deliverable()) { return false } args.send({ v: BRIDGE_PROTOCOL_VERSION, type: BRIDGE_BACK_FRAME }) return true }, drop, - read: () => ({ accepts, claimed }) + read: () => ({ claimed }) } } diff --git a/mobile/src/mobile-web-shell/bridge-host-contract.ts b/mobile/src/mobile-web-shell/bridge-host-contract.ts index f360fdc0d96..a9f9dfcb10b 100644 --- a/mobile/src/mobile-web-shell/bridge-host-contract.ts +++ b/mobile/src/mobile-web-shell/bridge-host-contract.ts @@ -5,7 +5,6 @@ import type { BridgeRefusal } from './bridge/bridge-caps' import type { BridgeInitHost, BridgeInitRoute } from './bridge/bridge-envelope' import type { BridgeClearableRouteParam } from './bridge/bridge-route-update' import type { BridgeSafeAreaInsets } from './bridge/bridge-safe-area-insets' -import type { PageReadyDeclaration } from './mobile-web-shell-session-contract' import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify' import type { BridgeErrorCapture } from './bridge/bridge-error-capture' import type { BridgeNativeVerb } from './bridge/bridge-native-verbs' @@ -133,6 +132,9 @@ export type BridgeHostOptions = { /** How much of the WebView sits under a system bar when it is drawn edge-to-edge. Zeros when * absent; moved later with `publishSafeAreaInsets`. */ safeAreaInsets?: BridgeSafeAreaInsets + /** The keyboard height native screens read on the shell's OS. 0 when absent; moved later with + * `publishKeyboardInset`. */ + keyboardInset?: number /** * This device's identity to that host, as the native screens already send it, swapped in for the * page's placeholder on the way out. Read at forward time rather than captured: the host outlives @@ -202,12 +204,8 @@ export type BridgeHostOptions = { * the same reason as the fault: the shell bounds the wait for it, and a host built without this * would leave a document that never spoke looking exactly like one still starting up. */ - onPageReady: (ready: PageReadyDeclaration) => void - /** - * The page has a frame on screen. Only pages whose `ready` listed `BRIDGE_PAGE_PAINTED` post it, - * which is why `onPageReady` carries that list: a caller covering the view until this arrives - * has to know whether it is coming, and a page served from an older desktop never sends one. - */ + onPageReady: () => void + /** The page has a frame on screen, which a caller covering the view until then waits for. */ onPagePainted: () => void /** * The page is holding the device Back key, or has let it go. Required, because the shell only diff --git a/mobile/src/mobile-web-shell/bridge-host-init.test.ts b/mobile/src/mobile-web-shell/bridge-host-init.test.ts index 6e2f86788f2..0b0b52687fc 100644 --- a/mobile/src/mobile-web-shell/bridge-host-init.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-init.test.ts @@ -7,10 +7,6 @@ import { BRIDGE_MAX_SUBSCRIPTIONS } from './bridge/bridge-caps' import { BRIDGE_FAULT_GRANT } from './bridge/bridge-envelope' -import { BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT } from './bridge/bridge-page-client-identity' -import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge/bridge-page-back' -import { BRIDGE_PAGE_PAINTED } from './bridge/bridge-page-painted' -import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge/bridge-route-update' import { routeViewOf } from './page-route-policy' describe('init and state', () => { @@ -29,13 +25,6 @@ describe('init and state', () => { type: 'init', sessionId: 'session-a', buildId: 'build-a', - // What this shell takes from the page, which is the page's own check before it posts one. - accepts: [ - BRIDGE_ROUTE_PARAM_CLEAR, - BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT, - BRIDGE_PAGE_PAINTED, - BRIDGE_BACK_CLAIM_NOTIFY - ], connection: { state: 'reconnecting', reconnectAttempt: 3, @@ -265,7 +254,7 @@ describe('init and state', () => { route: { pathname: '/h/host-a/session/wt-1' }, post: () => (view.gone ? Promise.reject(new Error('the view is gone')) : Promise.resolve()) }) - // A page that declares nothing is never sent a second `init`, so the tap can only be held. + // The second `init` the tap sends is refused with the view, so only the held route is left. bridge.host.receive(clientFrame({ type: 'ready' })) bridge.host.publishRoute({ pathname: '/h/host-a/session/wt-1', @@ -274,7 +263,7 @@ describe('init and state', () => { for (let turn = 0; turn < 4; turn += 1) { await Promise.resolve() } - expect(bridge.posted).toHaveLength(1) + expect(bridge.posted).toHaveLength(2) // The next document over the same host: a reload, or the view coming back. view.gone = false bridge.host.receive(clientFrame({ type: 'ready' })) diff --git a/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts b/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts index dc9436074e8..1e7b7904a10 100644 --- a/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host-notifications.test.ts @@ -6,8 +6,6 @@ import { BRIDGE_FAULT_GRANT, BRIDGE_NAVIGATE_BACK_NOTIFY } from './bridge/bridge-envelope' -import { BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT } from './bridge/bridge-page-client-identity' -import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge/bridge-page-back' import { BRIDGE_PAGE_PAINTED } from './bridge/bridge-page-painted' import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge/bridge-route-update' import { @@ -462,20 +460,6 @@ describe('the page erasing a one-shot route param', () => { expect(bridge.routeParamClears()).toEqual([]) expect(bridge.diagnostics).toEqual([{ kind: 'refused', refusal: 'unrecognised-message' }]) }) - - it('tells the page it takes a clear, so a page built for an older shell does not post one', () => { - const bridge = harness({ route: { pathname: '/h/host-a/session/wt-1' } }) - bridge.host.receive(clientFrame({ type: 'ready' })) - const init = bridge.last() - // Written out rather than compared against `BRIDGE_SHELL_ACCEPTS`: a list that pins itself - // pins nothing, and this is the frame an older page reads to decide what it may post. - expect(init.type === 'init' && init.accepts).toEqual([ - BRIDGE_ROUTE_PARAM_CLEAR, - BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT, - BRIDGE_PAGE_PAINTED, - BRIDGE_BACK_CLAIM_NOTIFY - ]) - }) }) /** @@ -485,20 +469,13 @@ describe('the page erasing a one-shot route param', () => { describe('the page reporting its first frame', () => { it('hands the report to the session and asks the client for nothing', () => { const bridge = harness() - bridge.host.receive(clientFrame({ type: 'ready', reports: [BRIDGE_PAGE_PAINTED] })) + bridge.host.receive(clientFrame({ type: 'ready' })) bridge.host.receive(clientFrame({ type: 'notify', name: BRIDGE_PAGE_PAINTED })) expect(bridge.pagePaintCount()).toBe(1) expect(bridge.client.requests).toHaveLength(0) expect(bridge.client.foregroundCalls).toHaveLength(0) }) - it('forwards what each ready declared, including a name this shell does not implement', () => { - const bridge = harness() - bridge.host.receive(clientFrame({ type: 'ready', reports: [BRIDGE_PAGE_PAINTED, 'weather'] })) - bridge.host.receive(clientFrame({ type: 'ready' })) - expect(bridge.pageReports()).toEqual([[BRIDGE_PAGE_PAINTED, 'weather'], []]) - }) - it('refuses a report from a document nothing has answered', () => { const bridge = harness() bridge.host.receive(clientFrame({ type: 'notify', name: BRIDGE_PAGE_PAINTED })) diff --git a/mobile/src/mobile-web-shell/bridge-host-route.ts b/mobile/src/mobile-web-shell/bridge-host-route.ts index 055932a5ffe..1306ea276a2 100644 --- a/mobile/src/mobile-web-shell/bridge-host-route.ts +++ b/mobile/src/mobile-web-shell/bridge-host-route.ts @@ -1,11 +1,6 @@ -import { - BridgeInitRouteSchema, - type BridgeClientMessage, - type BridgeInitRoute -} from './bridge/bridge-envelope' +import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge/bridge-envelope' import { readBridgeRouteUpdate } from './bridge/bridge-route-update' import { - BRIDGE_SAFE_AREA_ACCEPT, sameSafeAreaInsets, ZERO_SAFE_AREA_INSETS, type BridgeSafeAreaInsets @@ -17,13 +12,10 @@ export type BridgeHostRoute = { readonly current: () => BridgeInitRoute | null /** Why the opened route was refused, for the line the host prints at construction. */ readonly openIssue: () => string - /** What the page's latest `ready` said it can be sent. Reset by each document's `ready`. */ - readonly readReady: (message: Extract) => void /** * Hands the page a rewritten param for the screen it is already on, and sends one `init` when * that moved the route. The held route moves either way, so a page that reloads inside this - * mount is given the newest one on its next `ready` even when it is too old to be sent one in - * flight. + * mount is given the newest one on its next `ready`. * * Answers nothing, and nothing here learns anything from a post (ruling 34). It cannot: a frame * the page received and then failed to handle is caught by the page and reported there, so what @@ -36,10 +28,14 @@ export type BridgeHostRoute = { /** The insets the next `init` carries. */ readonly safeAreaInsets: () => BridgeSafeAreaInsets /** - * Moves the held insets, and sends one `init` when they moved and the page reads them. The same - * lane as a pane update: held either way, so the next `ready` carries them. + * Moves the held insets, and sends one `init` when they moved. The same lane as a pane update: + * held either way, so the next `ready` carries them. */ readonly publishSafeAreaInsets: (next: BridgeSafeAreaInsets, deliverable: boolean) => void + /** The keyboard height the next `init` carries. */ + readonly keyboardInset: () => number + /** Moves the held keyboard height on the same lane as the insets. */ + readonly publishKeyboardInset: (next: number, deliverable: boolean) => void } /** @@ -65,19 +61,17 @@ export function createBridgeHostRoute(args: { sendInit: () => void onRefused: (issue: string) => void safeAreaInsets?: BridgeSafeAreaInsets + keyboardInset?: number }): BridgeHostRoute { const parsed = BridgeInitRouteSchema.safeParse(args.opened) let route = parsed.success && !args.refused ? parsed.data : null - let accepts: readonly string[] = [] let insets = args.safeAreaInsets ?? ZERO_SAFE_AREA_INSETS + let keyboard = args.keyboardInset ?? 0 return { current: () => route, openIssue: () => (parsed.success ? 'unknown' : (parsed.error.issues[0]?.message ?? 'unknown')), - readReady: (message) => { - accepts = message.accepts ?? [] - }, publish: (next, deliverable) => { - const update = readBridgeRouteUpdate({ held: route, next, accepts, deliverable }) + const update = readBridgeRouteUpdate({ held: route, next, deliverable }) if (update.kind === 'refuse') { args.onRefused(update.issue) return @@ -93,8 +87,17 @@ export function createBridgeHostRoute(args: { return } insets = next - // Only to a page that reads them: a re-init to one that does not is a wasted frame per move. - if (deliverable && accepts.includes(BRIDGE_SAFE_AREA_ACCEPT)) { + if (deliverable) { + args.sendInit() + } + }, + keyboardInset: () => keyboard, + publishKeyboardInset: (next, deliverable) => { + if (keyboard === next) { + return + } + keyboard = next + if (deliverable) { args.sendInit() } } diff --git a/mobile/src/mobile-web-shell/bridge-host-test-harness.ts b/mobile/src/mobile-web-shell/bridge-host-test-harness.ts index 835698e24ac..5c3d602a4ed 100644 --- a/mobile/src/mobile-web-shell/bridge-host-test-harness.ts +++ b/mobile/src/mobile-web-shell/bridge-host-test-harness.ts @@ -50,7 +50,6 @@ export type Harness = { /** Every claim the host reported, in order, including the false it sends when a document ends. */ backClaims: boolean[] /** What each answered `ready` declared it reports, in order. */ - pageReports: () => readonly (readonly string[])[] /** One entry per `ready` answered, saying whether its `init` reached the page. Filled as each * post settles, so a case reads it after awaiting the turn the post resolves on. */ /** Every clear the page asked for, in order. */ @@ -120,7 +119,6 @@ export function harness( let pageReadies = 0 let pagePaints = 0 /** What each answered `ready` declared it reports, in order. */ - const pageReports: (readonly string[])[] = [] /** One entry per `ready` answered, saying whether an `init` actually went out for it. */ const routeParamClears: { param: string; value: string }[] = [] const routeRefusals: string[] = [] @@ -146,9 +144,8 @@ export function harness( readStorage: options.readStorage ?? (() => ({ storage: options.storage ?? {}, storageOversize: [] })), onStorageWrite: (key, value) => storageWrites.push({ key, value }), - onPageReady: ({ reports }) => { + onPageReady: () => { pageReadies += 1 - pageReports.push(reports) }, onPageBackClaim: (claimed) => backClaims.push(claimed), onPagePainted: () => { @@ -212,7 +209,6 @@ export function harness( pageReadyCount: () => pageReadies, pagePaintCount: () => pagePaints, backClaims, - pageReports: () => pageReports, routeParamClears: () => routeParamClears, routeRefusals, pageFaults, diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts index 28e25c2e452..9431b4500db 100644 --- a/mobile/src/mobile-web-shell/bridge-host.ts +++ b/mobile/src/mobile-web-shell/bridge-host.ts @@ -13,7 +13,7 @@ import { type BridgeInitRoute } from './bridge/bridge-envelope' import { BridgePageRouteGrantsSchema } from './bridge/bridge-page-route-grants' -import { BRIDGE_SHELL_ACCEPTS, createBridgeInitFrame } from './bridge/bridge-init-frame' +import { createBridgeInitFrame } from './bridge/bridge-init-frame' import { splitBridgeReply } from './bridge/bridge-reply-chunking' import { createBridgeHostFrames } from './bridge-host-frames' import { createBridgeHostBack, type BridgeSessionBack } from './bridge-host-back' @@ -31,9 +31,8 @@ export type BridgeHost = { * Hands this session a rewritten route: same screen, different params (ruling 33.1). * * The held route moves either way, so a page that reloads inside this mount is told the newest - * one; the frame goes out only to a page that declared `BRIDGE_ROUTE_UPDATE_ACCEPT`, because a - * page too old to name it reads a second `init` as a replacement. A different pathname is a - * different screen and is refused here — that is a remount, which is what the shell already does. + * one. A different pathname is a different screen and is refused here — that is a remount, + * which is what the shell already does. * * Answers nothing, and nothing is tracked (ruling 34): a frame the view refused is repaired by * the next `init`, and the request it carried is spent by the page, which erases the param it @@ -42,6 +41,8 @@ export type BridgeHost = { publishRoute: (next: BridgeInitRoute) => void /** Hands this session moved safe-area insets over the same re-sent `init` a route update takes. */ publishSafeAreaInsets: (next: BridgeSafeAreaInsets) => void + /** Hands this session a moved keyboard height over the same re-sent `init`. */ + publishKeyboardInset: (next: number) => void /** * Hands the page one Back press. False when this document never said it takes one, which is * every page older than the frame; the caller then leaves the key to the navigator. @@ -87,7 +88,8 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { sendInit() }, onRefused: (issue) => options.onDiagnostic?.({ kind: 'route-update-refused', issue }), - ...(options.safeAreaInsets === undefined ? {} : { safeAreaInsets: options.safeAreaInsets }) + ...(options.safeAreaInsets === undefined ? {} : { safeAreaInsets: options.safeAreaInsets }), + ...(options.keyboardInset === undefined ? {} : { keyboardInset: options.keyboardInset }) }) let closed = false // One document's turn at the bridge. `close` ends it and the next `ready` begins the next one; @@ -163,12 +165,12 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { connection: snapshot(), route, safeAreaInsets: routes.safeAreaInsets(), + keyboardInset: routes.keyboardInset(), pageRoutes, ...(parsedRouteGrants?.success === true ? { pageRouteGrants: parsedRouteGrants.data } : {}), granted, - accepts: BRIDGE_SHELL_ACCEPTS, host, ...options.readStorage() }) @@ -249,16 +251,12 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { // re-asked `ready` from the document already being served is answered the same way. if (message.type === 'ready') { serving = true - routes.readReady(message) - back.readReady(message.accepts ?? []) + back.readReady() // Every time it is asked, not once: the page re-asks on a backoff, and each ask is answered // with the route the shell holds now. That is the whole repair path for a frame that never // arrived (ruling 34) — nothing here waits on one, and nothing retries one. sendInit() - // Forwarded verbatim, including a name this shell has never implemented: what each report - // means is the caller's, and this host's job is that the list belongs to the document that - // just spoke rather than to the one before it. - options.onPageReady({ reports: message.reports ?? [], accepts: message.accepts ?? [] }) + options.onPageReady() return } if (!serving) { @@ -337,6 +335,9 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { publishSafeAreaInsets: (next) => { routes.publishSafeAreaInsets(next, deliverable()) }, + publishKeyboardInset: (next) => { + routes.publishKeyboardInset(next, deliverable()) + }, sendBack: back.send, readSessionBack: back.read, dispose diff --git a/mobile/src/mobile-web-shell/bridge/bridge-back-round-trip.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-back-round-trip.test.ts index 1ceb977eb71..a4940d4d9d8 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-back-round-trip.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-back-round-trip.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { BRIDGE_PROTOCOL_VERSION } from './bridge-envelope' -import { BRIDGE_BACK_CLAIM_NOTIFY, BRIDGE_BACK_FRAME } from './bridge-page-back' +import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back' import { createFakeBridgePortPair, type BridgePortPair } from './bridge-port-pair-test-harness' import type { FakeRpcClient } from '../bridge-host-test-fakes' @@ -58,9 +58,7 @@ describe('a Back press crossing from the shell to the page', () => { release() await pair.flush() // The shell still sends one, standing in for a claim that went stale in flight. - pair.host.receive( - JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready', accepts: [BRIDGE_BACK_FRAME] }) - ) + pair.host.receive(JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' })) await pair.flush() expect(pair.host.sendBack()).toBe(true) await pair.flush() @@ -83,18 +81,14 @@ describe('a Back press crossing from the shell to the page', () => { expect(pair.backClaims).toEqual([true]) // The page re-asks, which is what a stale `state` frame makes it do; the host drops the claim // answering it, and the page's re-assert is what puts the two back in step. - pair.host.receive( - JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready', accepts: [BRIDGE_BACK_FRAME] }) - ) + pair.host.receive(JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' })) await pair.flush() expect(pair.backClaims).toEqual([true, false, true]) }) it('says nothing again when this document is holding nothing', async () => { const pair = await opened() - pair.host.receive( - JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready', accepts: [BRIDGE_BACK_FRAME] }) - ) + pair.host.receive(JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' })) await pair.flush() expect(notifies(pair)).toEqual([]) expect(pair.backClaims).toEqual([]) @@ -111,66 +105,22 @@ describe('a Back press crossing from the shell to the page', () => { }) }) -/** - * Both halves of the mixed-version matrix. The page bundle is served by a desktop that updates - * independently of the installed shell, so one side older than the other is the normal state and - * each direction has to degrade to exactly what Back did before this lane: the navigator pops. - */ -describe('a shell and a page built either side of the Back lane', () => { - it('old shell, new page: the page never posts a claim it was not offered', async () => { - // The `init.accepts` this shell sends, with the claim taken back out of it on the wire — which - // is exactly the frame a shell built before the name would have posted. - const pair = createFakeBridgePortPair({ - rewriteToPage: (json) => { - const frame: Record = JSON.parse(json) - const accepts = frame.accepts - if (frame.type !== 'init' || !Array.isArray(accepts)) { - return json - } - return JSON.stringify({ - ...frame, - accepts: accepts.filter((name) => name !== BRIDGE_BACK_CLAIM_NOTIFY) - }) - } - }) - await pair.flush() - pair.client.claimBack(() => true) - await pair.flush() - expect(notifies(pair)).toEqual([]) - expect(pair.backClaims).toEqual([]) - // And the re-assert is held to the same declaration: a second `init` from a shell that never - // named the claim is still one the page says nothing back to. - pair.host.receive(JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' })) - await pair.flush() - expect(notifies(pair)).toEqual([]) - }) - - it('new shell, old page: the shell never sends a press the page would refuse', async () => { +describe('the Back lane with no negotiation', () => { + it('hands a press to any page it serves, whose ready declares nothing', async () => { const pair = await opened() - // The same document reloaded as a build that declares nothing, which is what a released page is. pair.host.receive(JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' })) await pair.flush() const before = pair.toPage.length - expect(pair.host.sendBack()).toBe(false) - await pair.flush() - expect(pair.toPage).toHaveLength(before) - }) - - /** Why that gate has to exist: an older page's reader takes the whole frame down rather than - * ignoring a type it has never heard of. */ - it('a page too old to read the frame refuses it whole', async () => { - const pair = createFakeBridgePortPair({ - rewriteToPage: (json) => - JSON.parse(json).type === BRIDGE_BACK_FRAME ? '{"v":1,"type":"b4ck"}' : json - }) - await pair.flush() - pair.host.receive( - JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready', accepts: [BRIDGE_BACK_FRAME] }) - ) - await pair.flush() expect(pair.host.sendBack()).toBe(true) await pair.flush() - expect(pair.diagnostics).toContainEqual({ kind: 'refused', refusal: 'unrecognised-message' }) - expect(pair.backPops).toEqual([]) + expect(pair.toPage.length).toBe(before + 1) + }) + + it('posts the claim to the shell with nothing offered in init', async () => { + const pair = await opened() + pair.client.claimBack(() => true) + await pair.flush() + expect(notifies(pair)).toEqual([BRIDGE_BACK_CLAIM_NOTIFY]) + expect(pair.backClaims).toEqual([true]) }) }) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-caps.ts b/mobile/src/mobile-web-shell/bridge/bridge-caps.ts index de7056a9b70..4e625884b84 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-caps.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-caps.ts @@ -138,15 +138,6 @@ export function isBridgeExternalLinkUrl(url: string): boolean { } export const BRIDGE_MAX_PAGE_ROUTES = 64 -/** - * What a page may say it can be sent, bounded the way every other list on the envelope is. - * - * A frame bound and not a vocabulary: the shell acts on the names it knows and ignores the rest, - * which is what lets a newer page declare one an older shell has never heard of. - */ -export const BRIDGE_MAX_PAGE_ACCEPTS = 16 -export const BRIDGE_MAX_PAGE_ACCEPT_CHARS = 64 - /** A host id, its name and its endpoint. Bounded because the page renders all three. */ export const BRIDGE_MAX_HOST_FIELD_CHARS = 1024 diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts index 609016f033e..24060668e24 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-init-handshake.ts @@ -1,8 +1,4 @@ import { BRIDGE_PROTOCOL_VERSION, type BridgeClientMessage } from './bridge-envelope' -import { BRIDGE_BACK_FRAME } from './bridge-page-back' -import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' -import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge-route-update' -import { BRIDGE_SAFE_AREA_ACCEPT } from './bridge-safe-area-insets' /** The page asks again until the shell answers; a session has no other way to start. */ export const BRIDGE_READY_RETRY_MIN_MS = 50 @@ -53,17 +49,8 @@ export function createBridgeInitHandshake(ask: () => void): BridgeInitHandshake } } -/** - * What the page says on every ask, because the shell reads it off whichever `ready` it answers. - * `accepts` is what this page build can be sent: a second `init` for the session it holds, the - * Back frame, and insets it pads for itself. `reports` runs the other way: what a shell may wait - * for this page to post, and the shell holds a frame over the view until it arrives. - */ +/** What the page says on every ask. It declares nothing: the shell serves no page older than its + * floor, so every page it talks to takes everything the shell sends. */ export function createPageReadyFrame(): Extract { - return { - v: BRIDGE_PROTOCOL_VERSION, - type: 'ready', - accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT, BRIDGE_BACK_FRAME, BRIDGE_SAFE_AREA_ACCEPT], - reports: [BRIDGE_PAGE_PAINTED] - } + return { v: BRIDGE_PROTOCOL_VERSION, type: 'ready' } } diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts index b900a095725..993782d8818 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts @@ -43,8 +43,6 @@ export type BridgeClientNotificationDeps = { isClosed: () => boolean /** What `init.grants.native` named. A grant the shell did not give is a frame it would refuse. */ hasGrant: (name: string) => boolean - /** What `init.accepts` named. The other half of the same read: a capability rather than a grant. */ - shellAccepts: (name: string) => boolean } export type BridgeClientNotifications = { @@ -140,25 +138,12 @@ export function createBridgeClientNotifications( error: captureBridgeError(error) }) }, - // Gated on the shell saying it takes one, not on a grant: `notify` is a closed union, so an - // older shell answers an unknown name with an error frame per mount. Answering nothing, because - // a page that has painted has nothing else to do about a shell that will not hear it. + // Not on a grant: every session takes it, and a page that has painted has nothing else to do. notifyPagePainted: () => { - if (deps.shellAccepts(BRIDGE_PAGE_PAINTED)) { - post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_PAGE_PAINTED }) - } + post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_PAGE_PAINTED }) }, - // Gated the same way, for the same reason, and answering nothing: a shell that will not hear - // the claim keeps the key, which is what every shell did before this frame existed. notifyBackClaim: (claimed) => { - if (deps.shellAccepts(BRIDGE_BACK_CLAIM_NOTIFY)) { - post({ - v: BRIDGE_PROTOCOL_VERSION, - type: 'notify', - name: BRIDGE_BACK_CLAIM_NOTIFY, - claimed - }) - } + post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_BACK_CLAIM_NOTIFY, claimed }) } } } diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-session.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-session.ts index 02d8a534829..09085cb2ec6 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-session.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-session.ts @@ -27,6 +27,8 @@ export type BridgeShellSession = { /** How much of the WebView sits under a system bar. Zeros for a shell that reserves the bars * outside the view, which is every shell before the field. */ safeAreaInsets: BridgeSafeAreaInsets + /** The keyboard height native screens read on the shell's OS; 0 while it is closed. */ + keyboardInset: number /** Null for a shell too old to name it; the page's own `loadHosts()` then answers with nothing. */ host: BridgeInitHost | null /** The allowlisted keys as the app held them when this page opened. */ @@ -34,14 +36,6 @@ export type BridgeShellSession = { /** The allowlisted keys `storage` could not carry because the app's value is over the page's cap * (ruling 33.6). Empty for a shell too old to name them, which is what it was before. */ storageOversize: readonly string[] - /** - * What this shell takes from the page beyond the frames every shell has taken (ruling 34). - * - * Empty for a shell that named none, which is every shell before this field: a page that posts - * one of these to one of those has the whole frame refused as `unrecognised-message`, so the - * check is the page's and it is made from here. - */ - accepts: readonly string[] } /** @@ -61,9 +55,9 @@ export function readShellSession( pageRoutes: message.pageRoutes ?? [], pageRouteGrants: message.pageRouteGrants ?? null, safeAreaInsets: message.safeAreaInsets ?? ZERO_SAFE_AREA_INSETS, + keyboardInset: message.keyboardInset ?? 0, host: message.host ?? null, storage: message.storage ?? {}, - storageOversize: message.storageOversize ?? [], - accepts: message.accepts ?? [] + storageOversize: message.storageOversize ?? [] } } diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts index db5614119e8..03181a4e03f 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-client-shell-session.ts @@ -31,6 +31,8 @@ export type BridgeClientShellSession = { onRouteUpdate: (listener: (route: BridgeInitRoute | null) => void) => () => void /** Fires when an `init` moved the safe-area insets the page holds, including a replacement's. */ onSafeAreaInsetsUpdate: (listener: (insets: BridgeSafeAreaInsets) => void) => () => void + /** Fires when an `init` moved the keyboard height the page holds, including a replacement's. */ + onKeyboardInsetUpdate: (listener: (height: number) => void) => () => void /** Drops the session and every listener waiting on one, which `close` is the only caller of. */ close: () => void } @@ -45,6 +47,7 @@ export function createBridgeClientShellSession(args: { const readyListeners = new Set<() => void>() const routeUpdateListeners = new Set<(route: BridgeInitRoute | null) => void>() const insetsListeners = new Set<(insets: BridgeSafeAreaInsets) => void>() + const keyboardListeners = new Set<(height: number) => void>() return { current: () => session, @@ -62,10 +65,12 @@ export function createBridgeClientShellSession(args: { // The held object when the values match, so a reader keyed on identity does not re-render // for a re-asked `ready`. const safeAreaInsets = held === null || movedInsets ? sent : held.safeAreaInsets + const keyboardInset = message.keyboardInset ?? 0 + const movedKeyboard = held !== null && held.keyboardInset !== keyboardInset session = update === null ? { ...readShellSession(message), safeAreaInsets } - : { ...update, route: message.route ?? null, safeAreaInsets } + : { ...update, route: message.route ?? null, safeAreaInsets, keyboardInset } // Re-primed either way, because a second `init` is also how the page recovers a cache it has // refused a `state` frame into: the shell rebuilt under it publishes a generation the page's // own is newer than, and this frame is what puts the two back in step. A pane update carries @@ -80,6 +85,11 @@ export function createBridgeClientShellSession(args: { listener(safeAreaInsets) } } + if (movedKeyboard) { + for (const listener of keyboardListeners) { + listener(keyboardInset) + } + } // Only a route that moved is an update. The shell answers every `ready` with the route it // holds, and the page re-asks after a refused `state` frame, so publishing each of those // would hand the pane hook the route it is already on. @@ -114,6 +124,12 @@ export function createBridgeClientShellSession(args: { insetsListeners.delete(listener) } }, + onKeyboardInsetUpdate: (listener) => { + keyboardListeners.add(listener) + return () => { + keyboardListeners.delete(listener) + } + }, close: () => { session = null readyListeners.clear() 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 056dcf7bb18..4542949ac32 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts @@ -34,7 +34,7 @@ import { type BridgeHostMessage, type BridgeReplyPayload } from './bridge-envelope' -import { BRIDGE_BACK_CLAIM_NOTIFY, BRIDGE_BACK_FRAME } from './bridge-page-back' +import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back' import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' const ID = 'AAAAAAAAAAAAAAAAAAAAAA' @@ -89,20 +89,12 @@ function client(fields: Record): Record { describe('client messages', () => { const accepted = [ ['ready', { type: 'ready' }], - ['ready naming what it reports', { type: 'ready', reports: [BRIDGE_PAGE_PAINTED] }], - // A shell with no row for the name reads a report it will never wait on, which is what an - // additive field has to look like in the older direction. - [ - 'ready naming a report this shell does not implement', - { type: 'ready', reports: ['weather'] } - ], ['a page painted notify', { type: 'notify', name: BRIDGE_PAGE_PAINTED }], ['a back claim', { type: 'notify', name: BRIDGE_BACK_CLAIM_NOTIFY, claimed: true }], [ 'a back claim being let go', { type: 'notify', name: BRIDGE_BACK_CLAIM_NOTIFY, claimed: false } ], - ['ready naming what it takes', { type: 'ready', accepts: [BRIDGE_BACK_FRAME] }], ['request without params', { type: 'request', id: ID, method: 'status.get' }], ['request with params', { type: 'request', id: ID, method: 'status.get', params: { a: 1 } }], [ diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts index ae8729f96ce..d24f1ab599c 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts @@ -7,6 +7,7 @@ import { BridgeErrorCaptureSchema } from './bridge-error-capture' import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge-init-route' import { BridgePageRouteGrantsSchema } from './bridge-page-route-grants' import { BridgeSafeAreaInsetsSchema } from './bridge-safe-area-insets' +import { BridgeKeyboardInsetSchema } from './bridge-keyboard-inset' import { BridgeNotifySchema } from './bridge-notify-envelope' import { BRIDGE_BACK_FRAME } from './bridge-page-back' import { BRIDGE_ID_PATTERN, idSchema, methodSchema, versionSchema } from './bridge-frame-fields' @@ -26,8 +27,6 @@ import { PAGE_STORAGE_MAX_VALUE_CHARS } from '../page-storage-keys' import { - BRIDGE_MAX_PAGE_ACCEPT_CHARS, - BRIDGE_MAX_PAGE_ACCEPTS, BRIDGE_MAX_PAGE_ROUTES, BRIDGE_MAX_REPLY_PARTS, BRIDGE_MAX_ROUTE_PATHNAME_CHARS, @@ -170,37 +169,7 @@ const replyPartSchema = z.object({ const BridgeClientMessageSchema = z.discriminatedUnion('type', [ z.object({ v: versionSchema, - type: z.literal('ready'), - /** - * What this page can be sent beyond its first `init`; the names and why live in - * `bridge-route-update.ts`, which is the only one there is. - * - * Optional, and safe in both directions without a version bump: a page that sends none is - * never sent a second `init`, and a shell that reads none never sends one. An unknown name is - * accepted by the schema and ignored by the shell, which is what a newer page declaring a - * capability this shell has never implemented has to look like. - */ - accepts: z - .array(z.string().min(1).max(BRIDGE_MAX_PAGE_ACCEPT_CHARS)) - .max(BRIDGE_MAX_PAGE_ACCEPTS) - .optional(), - /** - * What this page will post that the shell may have to wait for, which today is - * `BRIDGE_PAGE_PAINTED` and nothing else. - * - * `accepts` runs the other way and cannot stand in for this: it says what may be sent *to* the - * page. A shell waiting on a frame has to know the page will send one, because the generation - * is served by a desktop that updates independently of the installed shell — an undeclared - * wait would hide a working page built before the frame existed. - * - * Optional and additive in both directions, on the same bounds as `accepts`: a page that - * declares none is waited for by nothing, and an unknown name is a report this shell does not - * act on. - */ - reports: z - .array(z.string().min(1).max(BRIDGE_MAX_PAGE_ACCEPT_CHARS)) - .max(BRIDGE_MAX_PAGE_ACCEPTS) - .optional() + type: z.literal('ready') }), z.object({ v: versionSchema, @@ -282,8 +251,7 @@ const BridgeHostMessageSchema = z.union([ connection: BridgeConnectionSnapshotSchema }), // One Back press, and nothing else: the shell pops what it pushed, so a frame naming where to go - // back to would be naming a screen the page cannot see. Sent only to a page whose `ready` listed - // `BRIDGE_BACK_FRAME`, because an older page's reader refuses the whole frame. + // back to would be naming a screen the page cannot see. z.object({ v: versionSchema, type: z.literal(BRIDGE_BACK_FRAME) }), z.object({ v: versionSchema, @@ -307,6 +275,8 @@ const BridgeHostMessageSchema = z.union([ route: BridgeInitRouteSchema.optional(), /** How much of the WebView is under a system bar; absent reads as zeros. */ safeAreaInsets: BridgeSafeAreaInsetsSchema.optional(), + /** The keyboard height native screens read on the shell's OS; absent reads as 0. */ + keyboardInset: BridgeKeyboardInsetSchema.optional(), host: BridgeInitHostSchema.optional(), storage: BridgeInitStorageSchema.optional(), /** @@ -343,19 +313,7 @@ const BridgeHostMessageSchema = z.union([ * behaviour, an older page ignores it. The grant grammar is the manifest's own, so a name the * bundle could not have declared cannot arrive here either. */ - pageRouteGrants: BridgePageRouteGrantsSchema.optional(), - /** - * What this shell accepts from the page beyond the frames every shell has always taken, which - * today is `BRIDGE_ROUTE_PARAM_CLEAR` and nothing else. - * - * The mirror of `ready.accepts`, and optional for the same reason: a shell that sends none is - * never posted a clear, and a page that reads none never posts one. An unknown name is a - * capability this page has never heard of and is ignored. - */ - accepts: z - .array(z.string().min(1).max(BRIDGE_MAX_PAGE_ACCEPT_CHARS)) - .max(BRIDGE_MAX_PAGE_ACCEPTS) - .optional() + pageRouteGrants: BridgePageRouteGrantsSchema.optional() }) ]) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts b/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts index 582ed79548c..c31244c23ac 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts @@ -8,10 +8,6 @@ import { type BridgeInitHost, type BridgeInitRoute } from './bridge-envelope' -import { BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT } from './bridge-page-client-identity' -import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back' -import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' -import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge-route-update' import { sameSafeAreaInsets, ZERO_SAFE_AREA_INSETS, @@ -34,18 +30,6 @@ export const BRIDGE_NATIVE_GRANTS: readonly string[] = [ ...MOBILE_WEB_SHELL_GRANTS ] -/** - * What this shell accepts from a page beyond the frames every shell has always taken. Additive - * names on an optional list, so no version moves: a page that knows none posts none, one told - * nothing claims no identity, and one told nothing reports no paint. - */ -export const BRIDGE_SHELL_ACCEPTS: readonly string[] = [ - BRIDGE_ROUTE_PARAM_CLEAR, - BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT, - BRIDGE_PAGE_PAINTED, - BRIDGE_BACK_CLAIM_NOTIFY -] - /** The one frame that starts a session, built in one place so its caps and its grants agree. */ export function createBridgeInitFrame(args: { sessionId: string @@ -57,13 +41,13 @@ export function createBridgeInitFrame(args: { pageRoutes: readonly string[] /** How much of the WebView sits under a system bar, for a page that pads for them itself. */ safeAreaInsets?: BridgeSafeAreaInsets + /** The keyboard height native screens read on the shell's OS. */ + keyboardInset?: number /** What each of those patterns declared, so the page can tell a hop it may keep from one it * must hand back. Omitted by a shell that has none, which leaves the page on its old rule. */ pageRouteGrants?: readonly { pathname: string; grants: readonly string[] }[] /** What this session may do: the protocol's own grant plus what its route declared. */ granted: readonly string[] - /** What this shell takes from the page beyond the frames every shell has taken (ruling 34). */ - accepts?: readonly string[] /** The host the page is showing, minus the credential the bridge already carries for it. */ host: BridgeInitHost /** The allowlisted keys as the app holds them right now. */ @@ -94,12 +78,10 @@ export function createBridgeInitFrame(args: { sameSafeAreaInsets(args.safeAreaInsets, ZERO_SAFE_AREA_INSETS) ? {} : { safeAreaInsets: { ...args.safeAreaInsets } }), - pageRoutes: [...args.pageRoutes], - // Omitted when empty for the reason `storageOversize` is: a shell that declares nothing and - // one that declares an empty list are the same answer to the page's check. - ...(args.accepts === undefined || args.accepts.length === 0 + ...(args.keyboardInset === undefined || args.keyboardInset === 0 ? {} - : { accepts: [...args.accepts] }), + : { keyboardInset: args.keyboardInset }), + pageRoutes: [...args.pageRoutes], // Copied entry by entry for the reason the grants are: nothing the shell keeps may be // reachable through a frame it hands out. ...(args.pageRouteGrants === undefined diff --git a/mobile/src/mobile-web-shell/bridge/bridge-keyboard-inset.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-keyboard-inset.test.ts new file mode 100644 index 00000000000..9cdf24ae99b --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-keyboard-inset.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { createFakeBridgePortPair } from './bridge-port-pair-test-harness' +import { readBridgeHostMessage } from './bridge-envelope' +import { createBridgeInitFrame } from './bridge-init-frame' +import { readShellSession } from './bridge-client-session' + +function initFrame(keyboardInset?: number) { + return createBridgeInitFrame({ + sessionId: 'session-a', + buildId: 'build-a', + connection: { + state: 'connected', + reconnectAttempt: 0, + lastConnectedAt: null, + lastInboundAt: null, + generation: null + }, + route: { pathname: '/h/host-a' }, + pageRoutes: ['/h/[hostId]'], + granted: [], + host: { id: 'host-a', name: 'Host A', endpoint: 'ws://host-a', lastConnected: 0 }, + storage: {}, + ...(keyboardInset === undefined ? {} : { keyboardInset }) + }) +} + +function readInit(json: string) { + const read = readBridgeHostMessage(json) + if (!read.ok || read.message.type !== 'init') { + throw new Error( + `not an init the page would read: ${read.ok ? read.message.type : read.refusal}` + ) + } + return read.message +} + +describe('keyboardInset on init', () => { + it('reads an init without the field as 0, which is every shell before it', () => { + const init = readInit(JSON.stringify(initFrame())) + expect('keyboardInset' in init).toBe(false) + expect(readShellSession(init).keyboardInset).toBe(0) + }) + + it('passes the keyboard height through to the session the page holds', () => { + expect(readShellSession(readInit(JSON.stringify(initFrame(312)))).keyboardInset).toBe(312) + }) + + it('omits 0, since absent and closed are the same answer', () => { + expect('keyboardInset' in initFrame(0)).toBe(false) + }) + + it('refuses an init whose keyboard no device produces', () => { + for (const bad of [-1, 1e9, Number.NaN, '312']) { + const frame = { ...initFrame(), keyboardInset: bad } + expect(readBridgeHostMessage(JSON.stringify(frame)).ok, String(bad)).toBe(false) + } + }) +}) + +describe('a keyboard that moves under a live page', () => { + it('re-sends init on show, height change and hide, and the page reads each in place', async () => { + const pair = createFakeBridgePortPair() + await pair.flush() + const session = pair.client.getShellSession() + expect(session?.keyboardInset).toBe(0) + const seen: number[] = [] + pair.client.onKeyboardInsetUpdate((height) => seen.push(height)) + + // Pixel_API_37: the IME opens at 312, the suggestion strip grows it by 34, then it closes. + for (const height of [312, 346, 0]) { + pair.host.publishKeyboardInset(height) + await pair.flush() + expect(pair.client.getShellSession()?.keyboardInset).toBe(height) + } + expect(seen).toEqual([312, 346, 0]) + // Same session: a keyboard update is not a replacement. + expect(pair.client.getShellSession()?.sessionId).toBe(session?.sessionId) + expect(pair.client.getShellSession()?.route).toEqual(session?.route) + }) + + it('sends nothing for a height that did not move', async () => { + const pair = createFakeBridgePortPair({ keyboardInset: 312 }) + await pair.flush() + const inits = () => pair.readToPage().filter((frame) => frame.type === 'init').length + const before = inits() + pair.host.publishKeyboardInset(312) + await pair.flush() + expect(inits()).toBe(before) + expect(pair.client.getShellSession()?.keyboardInset).toBe(312) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-keyboard-inset.ts b/mobile/src/mobile-web-shell/bridge/bridge-keyboard-inset.ts new file mode 100644 index 00000000000..c11a642e8c2 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-keyboard-inset.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' +import { BRIDGE_MAX_SAFE_AREA_INSET } from './bridge-safe-area-insets' + +/** + * The software keyboard's height as native screens read it on the shell's OS (iOS from the + * window's bottom, Android above the bars), in the page's px; 0 while it is closed. The shell + * overlays the IME on the view like a native screen, and the page cannot measure it. + */ +export const BridgeKeyboardInsetSchema = z.number().finite().min(0).max(BRIDGE_MAX_SAFE_AREA_INSET) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts index d363be3f5fd..04683d78739 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts @@ -37,11 +37,10 @@ const BRIDGE_NOTIFY_GRANTS: Readonly> = // The protocol's own as well: it spends a request this shell handed the page, on a param closed // to the one the shell hands over, so there is nothing here for a grant to gate. [BRIDGE_ROUTE_PARAM_CLEAR]: null, - // The page reporting on its own document. Nothing here reaches the host or the device, and the - // shell acts on it only for a page that declared it in `ready.reports`. + // The page reporting on its own document. Nothing here reaches the host or the device. [BRIDGE_PAGE_PAINTED]: null, // The page reporting on its own document as well. A claim is not a capability: the shell decides - // whether to hand the key over, and it acts on this only for a page that declared it takes one. + // whether to hand the key over. [BRIDGE_BACK_CLAIM_NOTIFY]: null, navigate: 'navigate', [BRIDGE_NAVIGATE_BACK_NOTIFY]: 'navigate', diff --git a/mobile/src/mobile-web-shell/bridge/bridge-page-back.ts b/mobile/src/mobile-web-shell/bridge/bridge-page-back.ts index 87d08d46d6f..e7c151f63e4 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-page-back.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-page-back.ts @@ -1,12 +1,8 @@ -/** - * The device Back key, in the two places it is named: the page's claim on it, and the frame that - * hands one press over. Negotiated both ways, because each is a closed list on the other side — a - * claim to a shell that never advertised it is an error frame per mount on every shell installed, - * and a `back` to a page that never asked for one is a frame its reader drops. - */ +/** The device Back key, in the two places it is named: the page's claim on it, and the frame that + * hands one press over. */ -/** Page to shell, on `init.accepts`: this document is holding Back, or has let it go. */ +/** Page to shell, a notify: this document is holding Back, or has let it go. */ export const BRIDGE_BACK_CLAIM_NOTIFY = 'back-claim' -/** Shell to page, on `ready.accepts`: one Back press, handed to whoever claimed it. */ +/** Shell to page, a frame: one Back press, handed to whoever claimed it. */ export const BRIDGE_BACK_FRAME = 'back' diff --git a/mobile/src/mobile-web-shell/bridge/bridge-page-client-identity.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-page-client-identity.test.ts index bda9737f02f..b65a8faeee2 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-page-client-identity.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-page-client-identity.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { BRIDGE_PAGE_CLIENT_ID, - BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT, BridgePageClientIdentityUnavailableError, substituteBridgePageClientIdentity } from './bridge-page-client-identity' @@ -39,7 +38,6 @@ describe('the placeholder a page claims', () => { // The composer's send journal refuses a retained operation whose caller changed, and it has no // expiry. A per-document identity would turn "send it again" into a permanent refusal. expect(BRIDGE_PAGE_CLIENT_ID).toBe('orca-page-client') - expect(BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT).toBe('page-client-identity') }) it('becomes the device identity in both fields a page carries one in', () => { @@ -184,12 +182,6 @@ describe('the shell substitutes on both doors to the client', () => { } }) }) - - it('tells the page it performs the swap, on a list an older page ignores', () => { - const bridge = harness({ ready: true }) - const init = bridge.frames().find((message) => message.type === 'init') - expect(init?.accepts).toContain(BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT) - }) }) describe('what the page hears when the shell has no identity', () => { diff --git a/mobile/src/mobile-web-shell/bridge/bridge-page-client-identity.ts b/mobile/src/mobile-web-shell/bridge/bridge-page-client-identity.ts index a23a8b4c319..44277fa0d24 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-page-client-identity.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-page-client-identity.ts @@ -8,9 +8,6 @@ /** What a page puts in `client.id`. Fixed, so a resent message fingerprints the same caller. */ export const BRIDGE_PAGE_CLIENT_ID = 'orca-page-client' -/** `init.accepts` name for a shell that swaps. A page told nothing claims no identity at all. */ -export const BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT = 'page-client-identity' - /** The two fields a page carries an identity in. `mobileClient` is native chat's spelling. */ const IDENTITY_FIELDS = ['client', 'mobileClient'] as const diff --git a/mobile/src/mobile-web-shell/bridge/bridge-page-painted.ts b/mobile/src/mobile-web-shell/bridge/bridge-page-painted.ts index effa3c07a05..41266b01fe0 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-page-painted.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-page-painted.ts @@ -1,6 +1,2 @@ -/** - * The page's first frame, in all three places it is named: `ready.reports`, the notify, and - * `init.accepts`. Negotiated both ways, because the notify union is closed — a report to a shell - * that never advertised it is an error frame per mount on every shell already installed. - */ +/** The notify a page posts when its first frame is on screen; the shell covers the view until it. */ export const BRIDGE_PAGE_PAINTED = 'painted' diff --git a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.test.ts index 705360361db..ac532410aa0 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.test.ts @@ -2,10 +2,6 @@ import { describe, expect, it } from 'vitest' import type { RpcClient } from '../../transport/rpc-client' import type { RpcResponse } from '../../transport/types' import { createBridgePortPair, createFakeBridgePortPair } from './bridge-port-pair-test-harness' -import { BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT } from './bridge-page-client-identity' -import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back' -import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' -import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge-route-update' /** Every member of the contract, none of them a fake anything: the pair must carry a plain client. */ function plainShellClient(record: string[]): RpcClient { @@ -43,15 +39,10 @@ describe('the bridge port pair', () => { pageRoutes: expect.any(Array), pageRouteGrants: null, safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + keyboardInset: 0, host: expect.objectContaining({ id: expect.any(String) }), storage: expect.any(Object), - storageOversize: [], - accepts: [ - BRIDGE_ROUTE_PARAM_CLEAR, - BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT, - BRIDGE_PAGE_PAINTED, - BRIDGE_BACK_CLAIM_NOTIFY - ] + storageOversize: [] }) }) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts index bf9b611a540..1b329207d53 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts @@ -60,7 +60,6 @@ export type BridgePortPair = { /** Every claim on the device Back key the host reported, in order. */ readonly backClaims: boolean[] /** What each answered `ready` declared it reports, in order. */ - readonly pageReports: () => readonly (readonly string[])[] /** Every clear the page asked the shell for, in order. */ readonly routeParamClears: () => readonly { param: string; value: string }[] /** Why the host refused to open a session at all, if it did. */ @@ -105,6 +104,7 @@ export type BridgePortPairOptions = { /** Replaces the verb handler, for the arms where the shell refuses rather than answers. */ serveNativeVerb?: (verb: BridgeNativeVerb, params: unknown) => Promise safeAreaInsets?: BridgeSafeAreaInsets + keyboardInset?: number } type Lane = { @@ -210,7 +210,6 @@ export function createBridgePortPair( let pageReadies = 0 let pagePaints = 0 /** What each answered `ready` declared it reports, in order. */ - const pageReports: (readonly string[])[] = [] const routeParamClears: { param: string; value: string }[] = [] const routeRefusals: string[] = [] let receiveOnPage: ((json: string) => void) | null = null @@ -229,6 +228,7 @@ export function createBridgePortPair( sessionId: options.sessionId ?? 'session-a', route: options.route ?? { pathname: '/h/host-a' }, ...(options.safeAreaInsets === undefined ? {} : { safeAreaInsets: options.safeAreaInsets }), + ...(options.keyboardInset === undefined ? {} : { keyboardInset: options.keyboardInset }), readClientIdentity: () => options.clientIdentity === undefined ? PORT_PAIR_CLIENT_IDENTITY : options.clientIdentity, pageRoutes: options.pageRoutes ?? ['/h/[hostId]'], @@ -254,9 +254,8 @@ export function createBridgePortPair( }), onStorageWrite: (key, value) => storageWrites.push({ key, value }), onPageFault: (error) => pageFaults.push(error), - onPageReady: ({ reports }) => { + onPageReady: () => { pageReadies += 1 - pageReports.push(reports) }, onPagePainted: () => { pagePaints += 1 @@ -299,7 +298,6 @@ export function createBridgePortPair( pageReadyCount: () => pageReadies, pagePaintCount: () => pagePaints, backClaims, - pageReports: () => pageReports, routeParamClears: () => routeParamClears, routeRefusals, async flush(): Promise { diff --git a/mobile/src/mobile-web-shell/bridge/bridge-route-update.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-route-update.test.ts index aaef95a00ff..ff3b1c0b3bb 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-route-update.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-route-update.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { createFakeBridgePortPair } from './bridge-port-pair-test-harness' import type { BridgeInitRoute } from './bridge-envelope' -import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge-route-update' const SESSION = '/h/host-a/session/wt-1' @@ -79,17 +78,13 @@ describe('a route update over a re-sent init', () => { it('publishes nothing for a re-asked ready that carries the route the page holds', async () => { const pair = await openedOnTheSession() const seen = recordRouteUpdates(pair) - pair.host.receive( - JSON.stringify({ v: 1, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] }) - ) + pair.host.receive(JSON.stringify({ v: 1, type: 'ready' })) await pair.flush() expect(seen).toEqual([]) // And the same ask after a real update still leaves exactly the one delivery behind it. pair.host.publishRoute(sessionRoute('pane-1')) await pair.flush() - pair.host.receive( - JSON.stringify({ v: 1, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] }) - ) + pair.host.receive(JSON.stringify({ v: 1, type: 'ready' })) await pair.flush() expect(seen).toEqual(['pane-1']) }) @@ -114,32 +109,13 @@ describe('a route update over a re-sent init', () => { expect(pair.client.getShellSession()?.storage).toBe(before) }) - /** - * Wire compatibility, the half a schema cannot state: an older page never declares `accepts`, so - * the shell must not post it a second `init`. That page would read one as a replacement and - * settle every request it held; the tap it degrades to losing is what it loses today. - */ - it('sends no second init to a page that never declared it accepts one', async () => { + it('sends the second init to a page whose ready declares nothing', async () => { const pair = await openedOnTheSession() - expect(pair.readToShell().find((frame) => frame.type === 'ready')?.accepts).toContain( - BRIDGE_ROUTE_UPDATE_ACCEPT - ) - // This page, then the same document reloaded as a build that declares nothing -- which is what - // a released page is. The host reads `accepts` off whichever `ready` it last answered. pair.host.receive(JSON.stringify({ v: 1, type: 'ready' })) await pair.flush() const framesBefore = pair.toPage.length pair.host.publishRoute(sessionRoute('pane-1')) await pair.flush() - expect(pair.toPage.length).toBe(framesBefore) - // And the contrast, so the case fails when the gate stops gating rather than when it starts. - pair.host.receive( - JSON.stringify({ v: 1, type: 'ready', accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT] }) - ) - await pair.flush() - const framesAfterReady = pair.toPage.length - pair.host.publishRoute(sessionRoute('pane-2')) - await pair.flush() - expect(pair.toPage.length).toBe(framesAfterReady + 1) + expect(pair.toPage.length).toBe(framesBefore + 1) }) }) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-route-update.ts b/mobile/src/mobile-web-shell/bridge/bridge-route-update.ts index 93e42564424..1f5d3c00e5a 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-route-update.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-route-update.ts @@ -2,23 +2,15 @@ import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge-init-route import { shellScreenRouteKey } from '../shell-screen-route' /** - * The one thing a page can say it accepts, which is a second `init` for the session it already has. - * * A notification tap for another pane of the session on screen rewrites one route param of a page * that is already mounted, and the shell has no push lane to deliver it on: `notify` runs * page-to-shell only, and a shell-to-page frame kind would need a capability of its own * (`bridge-audio-verbs.ts` refused one for raw PCM). `init` already carries `route`, is already - * re-sent on every `ready` and is already re-read, so the pane request rides it. - * - * Declared by the page rather than assumed by the shell, in both directions. A page too old to - * name it reads a second `init` as a replacement and settles every request it holds, so a shell - * that sent one unasked would break it; a shell too old to re-send one leaves a newer page exactly - * where it is today. Both degrade to the repeat tap doing nothing, which is what it does now. + * re-sent on every `ready` and is already re-read, so the pane request rides a second `init`. */ -export const BRIDGE_ROUTE_UPDATE_ACCEPT = 'route-update' /** - * The one thing a shell can say it accepts, which is a page erasing a one-shot route param. + * The notify a page posts to erase a one-shot route param. * * The reader erases (ruling 34). A notification tap writes `paneKey` onto the native route, the * shell re-sends `init` for as long as it holds one, and the page — having applied the pane — asks @@ -28,10 +20,6 @@ export const BRIDGE_ROUTE_UPDATE_ACCEPT = 'route-update' * Every one of those is followed by a fresh document's `ready`, answered with the route the shell * holds then. A clear naming a pane the tap has already moved past is refused by the comparison * rather than by a sequence number. - * - * Declared in `init` rather than assumed, in the direction `ready.accepts` runs the other way. No - * shipped shell serves a page, so nothing needs negotiating today; the declaration is here so the - * page's check exists from the first version that can post one. */ export const BRIDGE_ROUTE_PARAM_CLEAR = 'route-param-clear' @@ -67,8 +55,8 @@ export function bridgeRouteMoved( * What a host does with a rewritten route: hold it for the next `init`, send one now, or refuse. * * `hold` and `send` both move what the host will publish, because a page that reloads inside this - * mount must be handed the newest route on its next `ready` even when it is too old to be sent one - * in flight. Only `send` is a frame, and only a frame lets the caller clear the param it delivered. + * mount must be handed the newest route on its next `ready`. Only `send` is a frame, and only a + * frame lets the caller clear the param it delivered. */ export type BridgeRouteUpdate = | { readonly kind: 'send'; readonly route: BridgeInitRoute } @@ -86,8 +74,6 @@ export type BridgeRouteUpdate = export function readBridgeRouteUpdate(args: { held: BridgeInitRoute | null next: BridgeInitRoute - /** What the page's last `ready` declared. Empty for every page built before this existed. */ - accepts: readonly string[] /** Whether this host has a served document that has already had an `init`. */ deliverable: boolean }): BridgeRouteUpdate { @@ -100,6 +86,5 @@ export function readBridgeRouteUpdate(args: { return { kind: 'refuse', issue: 'not-this-screen' } } const moved = bridgeRouteMoved(held, parsed.data) - const sendable = moved && args.deliverable && args.accepts.includes(BRIDGE_ROUTE_UPDATE_ACCEPT) - return { kind: sendable ? 'send' : 'hold', route: parsed.data } + return { kind: moved && args.deliverable ? 'send' : 'hold', route: parsed.data } } diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts index 55cff6ace69..f651e23b080 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts @@ -12,10 +12,6 @@ import { BRIDGE_ACK_INTERVAL_FRAMES } from './bridge-client-subscriptions' import { BRIDGE_PROTOCOL_VERSION, type BridgeHostMessage } from './bridge-envelope' -import { BRIDGE_BACK_FRAME } from './bridge-page-back' -import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' -import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge-route-update' -import { BRIDGE_SAFE_AREA_ACCEPT } from './bridge-safe-area-insets' import { BRIDGE_READY_RETRY_MAX_MS, BRIDGE_READY_RETRY_MIN_MS @@ -43,16 +39,9 @@ afterEach(() => { }) describe('bridge client handshake', () => { - it('asks for a session as soon as it exists, naming what it can be sent and what it reports', () => { + it('asks for a session as soon as it exists, declaring nothing', () => { const page = createPageClient() - expect(page.frames()).toEqual([ - { - v: BRIDGE_PROTOCOL_VERSION, - type: 'ready', - accepts: [BRIDGE_ROUTE_UPDATE_ACCEPT, BRIDGE_BACK_FRAME, BRIDGE_SAFE_AREA_ACCEPT], - reports: [BRIDGE_PAGE_PAINTED] - } - ]) + expect(page.frames()).toEqual([{ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }]) }) it('keeps asking on a widening backoff until init answers', () => { @@ -141,13 +130,11 @@ describe('bridge client handshake', () => { pageRouteGrants: null, // And no insets, which is the shell that reserves the bar strips outside the view. safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + keyboardInset: 0, // And no host and no stored keys, which is what `host-store.web.ts` then answers with. host: null, storage: {}, - storageOversize: [], - // And one that takes nothing from the page beyond the frames every shell has taken, which - // is what stops the page posting an erase it would refuse whole (ruling 34). - accepts: [] + storageOversize: [] }) }) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts index 452bcceb57c..687d26637f7 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts @@ -77,6 +77,8 @@ export type BridgeRpcClient = RpcClient & { onRouteUpdate: (listener: (route: BridgeInitRoute | null) => void) => () => void /** Fires when an `init` moved the safe-area insets; the value itself is on `getShellSession`. */ onSafeAreaInsetsUpdate: (listener: (insets: BridgeSafeAreaInsets) => void) => () => void + /** Fires when an `init` moved the keyboard height; the value itself is on `getShellSession`. */ + onKeyboardInsetUpdate: (listener: (height: number) => void) => () => void getShellSession: () => BridgeShellSession | null /** * Asks the shell to open a screen this page does not render. False when the shell granted no @@ -108,7 +110,6 @@ export type BridgeRpcClient = RpcClient & { /** * Tells the shell this document has a frame on screen, which is the only thing that does: the * shell sees a document commit and a page say `ready`, and neither of those is a painted tree. - * Declared in `ready.reports`, so a shell waiting for it is one this page will answer. */ notifyPagePainted: () => void /** @@ -141,9 +142,8 @@ export type BridgeRpcClient = RpcClient & { * (ruling 34). The reader erases: the shell tracks no delivery, so a request stays on the route * and keeps arriving until the page that applied it says so. * - * False when this shell never declared it takes one, which is every shell older than the field. - * Nothing is owed the caller either way — a clear that did not leave is repaired by the request - * arriving again, which is the same path a lost frame takes. + * False when the frame could not leave. Nothing is owed the caller — a clear that did not leave + * is repaired by the request arriving again, which is the same path a lost frame takes. */ clearRouteParam: (param: BridgeClearableRouteParam, value: string) => boolean } @@ -292,8 +292,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp subscriptions, report, // Declared here rather than beside the others: the re-assert has to run after the session has - // taken this frame, so the claim's own gate reads this `init`'s `accepts` and a shell that - // never named it still hears nothing. + // taken this frame. acceptInit: (message) => { handshake.stop() shellSession.accept(message) @@ -412,8 +411,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp send: posted, requireSession, isClosed: () => closed, - hasGrant: (name) => shellSession.current()?.grants.native.includes(name) === true, - shellAccepts: (name) => shellSession.current()?.accepts.includes(name) === true + hasGrant: (name) => shellSession.current()?.grants.native.includes(name) === true }) const unsubscribeFromMessages = options.onMessage(receive) @@ -465,9 +463,9 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp onReady: shellSession.onReady, onRouteUpdate: shellSession.onRouteUpdate, onSafeAreaInsetsUpdate: shellSession.onSafeAreaInsetsUpdate, + onKeyboardInsetUpdate: shellSession.onKeyboardInsetUpdate, getShellSession: shellSession.current, clearRouteParam: (param, value) => - shellSession.current()?.accepts.includes(BRIDGE_ROUTE_PARAM_CLEAR) === true && posted({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', diff --git a/mobile/src/mobile-web-shell/bridge/bridge-safe-area-insets.ts b/mobile/src/mobile-web-shell/bridge/bridge-safe-area-insets.ts index ada32878371..67d42c774c4 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-safe-area-insets.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-safe-area-insets.ts @@ -41,10 +41,3 @@ export function sameSafeAreaInsets( left.left === right.left ) } - -/** - * What a page puts in `ready.accepts` to say it pads for the system bars itself. The shell draws - * the WebView edge-to-edge only for such a page: an older page has no reader for the insets and - * would lay its header under the status bar, so it keeps the strips the shell reserves. - */ -export const BRIDGE_SAFE_AREA_ACCEPT = 'safe-area-insets' 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 86d5189db5e..c4962ebe16c 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 @@ -1,37 +1,18 @@ /** - * The paint report end to end, and the three pairings it has to survive: a shell that never - * advertised it, a page that never declared it, and two halves that did both. - * - * Driven through the real port pair rather than a mocked host, because what is worth proving is - * that each half reads the other's word off a real frame rather than assuming it. + * The paint report end to end, driven through the real port pair rather than a mocked host, because + * 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 { BRIDGE_PAGE_PAINTED } from './bridge-page-painted' import { createRouteScreenPaintReporter } from './page-first-paint' -/** One `init` frame with one name taken out of `accepts`, which is what an older shell sends. */ -function stripAccept(json: string, name: string): string { - const frame: unknown = JSON.parse(json) - if (typeof frame !== 'object' || frame === null || !('type' in frame)) { - return json - } - const record: Record = { ...frame } - if (record.type !== 'init' || !Array.isArray(record.accepts)) { - return json - } - record.accepts = record.accepts.filter((entry) => entry !== name) - return JSON.stringify(record) -} - describe('the page telling the shell it has a frame', () => { - it('new shell, new page: declares on ready and posts after the first paint', async () => { + it('posts after the first paint, and not before', async () => { const pair = createFakeBridgePortPair({ rpc: createFakeRpcClient() }) await pair.flush() - expect(pair.pageReports()).toEqual([[BRIDGE_PAGE_PAINTED]]) expect(pair.pagePaintCount()).toBe(0) // The two frames the entry waits out, drained by hand so "after the paint" is a step. @@ -52,31 +33,6 @@ describe('the page telling the shell it has a frame', () => { expect(pair.pagePaintCount()).toBe(1) }) - it('old shell, new page: posts nothing, so no shell is left refusing a frame', async () => { - // Stands for every shell installed before this change: `notify` is a closed union there, so an - // unasked-for report is an error frame per mount rather than a dropped one. - const pair = createFakeBridgePortPair({ - rpc: createFakeRpcClient(), - rewriteToPage: (json) => stripAccept(json, BRIDGE_PAGE_PAINTED) - }) - await pair.flush() - expect(pair.client.getShellSession()?.accepts).not.toContain(BRIDGE_PAGE_PAINTED) - const framesToShell = pair.toShell.length - pair.client.notifyPagePainted() - await pair.flush() - expect(pair.toShell).toHaveLength(framesToShell) - expect(pair.pagePaintCount()).toBe(0) - expect(pair.hostDiagnostics).not.toContainEqual( - expect.objectContaining({ refusal: 'unrecognised-message' }) - ) - }) - - it('advertises the report in init, which is what the page reads before posting one', async () => { - const pair = createFakeBridgePortPair({ rpc: createFakeRpcClient() }) - await pair.flush() - expect(pair.client.getShellSession()?.accepts).toContain(BRIDGE_PAGE_PAINTED) - }) - 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. diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-back-claim-state.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-back-claim-state.test.ts index 29c0d802860..b75cd2b5447 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-back-claim-state.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-back-claim-state.test.ts @@ -13,7 +13,7 @@ describe('the page claiming the device Back key', () => { function claimed(): MobileWebShellStep { return run( readySession().session, - { type: 'page-ready', reports: [], accepts: [] }, + { type: 'page-ready' }, { type: 'page-back-claim', claimed: true } ) } @@ -38,7 +38,7 @@ describe('the page claiming the device Back key', () => { }) it('drops it when the document asks for a session again, so the page has to claim again', () => { - const reasked = run(claimed().session, { type: 'page-ready', reports: [], accepts: [] }) + const reasked = run(claimed().session, { type: 'page-ready' }) expect(shellPageBackClaimed(reasked.session)).toBe(false) }) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-failed-update-fallback.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-failed-update-fallback.test.ts index 7814b33c73f..3ae35fc7a6e 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-failed-update-fallback.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-failed-update-fallback.test.ts @@ -122,6 +122,7 @@ function manifestOf( buildId: computeMobileWebBundleId(assets), minCompatibleRuntimeProtocolVersion: 2, runtimeProtocolVersion, + pageVersion: 1, entrypoint: 'index.html', totalBytes: TOTAL_BYTES, assets, 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 new file mode 100644 index 00000000000..bc0c93968c9 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-keyboard-overlay-screen.test.tsx @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const harness = await vi.hoisted(async () => await import('./mobile-web-shell-screen-test-harness')) +const dependencies = vi.hoisted(() => harness.createScreenDependencies()) + +const { screenModuleMocks } = await vi.hoisted( + async () => await import('./mobile-web-shell-screen-test-mocks') +) +const mocks = vi.hoisted(() => screenModuleMocks(dependencies)) +vi.mock('react-native', mocks['react-native']) +vi.mock('expo-clipboard', mocks['expo-clipboard']) +vi.mock('expo-haptics', mocks['expo-haptics']) +vi.mock('expo-document-picker', mocks['expo-document-picker']) +vi.mock('@orca/expo-two-way-audio', mocks['@orca/expo-two-way-audio']) +vi.mock('expo-keep-awake', mocks['expo-keep-awake']) +vi.mock('expo-image-picker', mocks['expo-image-picker']) +vi.mock('expo-file-system', mocks['expo-file-system']) +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('../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']) + +import { act } from 'react-test-renderer' +import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes' +import { + byName, + readyState, + renderScreen as mountScreen +} from './mobile-web-shell-screen-test-harness' +import { readBridgeHostMessage } from './bridge/bridge-envelope' +import { MobileWebShellScreen } from './MobileWebShellScreen' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' +import type { ReactTestRenderer } from 'react-test-renderer' + +const renderScreen = (state: MobileWebShellSessionState): Promise => + mountScreen(MobileWebShellScreen, dependencies, state) + +afterEach(harness.unmountRenderedScreens) + +beforeEach(() => { + harness.resetScreenDependencies(dependencies) +}) + +const WINDOW = { top: 44, right: 0, bottom: 8, left: 0 } + +/** + * The page is covered by the keyboard like a native screen: the view keeps its size, and the page + * is told the height on every keyboard event. + */ +describe('a page the keyboard covers', () => { + async function openPage(sessionId: string) { + dependencies.client = createFakeRpcClient() + const tree = await renderScreen(readyState(sessionId)) + await act(async () => { + byName(tree, 'ShellViewProbe')[0]?.props.onBridgeMessage({ + nativeEvent: { json: clientFrame({ type: 'ready' }) } + }) + }) + const root = () => tree.root.find((node) => node.props.testID === 'mobile-web-shell-ready') + const inits = () => + dependencies.posted.flatMap((json) => { + const read = readBridgeHostMessage(json) + return read.ok && read.message.type === 'init' + ? [{ keyboard: read.message.keyboardInset ?? 0, insets: read.message.safeAreaInsets }] + : [] + }) + const keyboard = async (height: number) => { + await act(async () => { + const ios = dependencies.platform === 'ios' + const name = + height > 0 + ? ios + ? 'keyboardWillShow' + : 'keyboardDidShow' + : ios + ? 'keyboardWillHide' + : 'keyboardDidHide' + dependencies.keyboardListeners.get(name)?.({ endCoordinates: { height } }) + }) + } + return { root, inits, keyboard } + } + + for (const platform of ['ios', 'android'] as const) { + it(`never shortens the view, and publishes show, a height change and hide (${platform})`, async () => { + dependencies.platform = platform + const page = await openPage(`session-overlay-${platform}`) + // Pixel_API_37: 312 up, 346 with the suggestion strip, then closed. The inset tracks each, so + // a keyboard that changes height while open leaves no gap above it. + for (const height of [312, 346, 0]) { + await page.keyboard(height) + expect(page.root().props.style[1]).toEqual({ paddingTop: 0 }) + } + // The keyboard's own height as native screens read it on this OS, home indicator included on + // iOS; the page's arithmetic branches on the shell's OS, as native does. The bottom inset + // stays the window's. + expect(page.inits()).toEqual([ + { keyboard: 0, insets: WINDOW }, + { keyboard: 312, insets: WINDOW }, + { keyboard: 346, insets: WINDOW }, + { keyboard: 0, insets: WINDOW } + ]) + }) + } +}) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-offline-grant-staleness.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-offline-grant-staleness.test.ts index c39c5560464..1fb8acee474 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-offline-grant-staleness.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-offline-grant-staleness.test.ts @@ -110,6 +110,7 @@ function manifestGranting( buildId: BUILD_ID, minCompatibleRuntimeProtocolVersion: 2, runtimeProtocolVersion: 5, + pageVersion: 1, entrypoint: 'index.html', totalBytes: TOTAL_BYTES, assets: ASSETS, 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 e8fcf93aad7..446f57b5062 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 @@ -31,8 +31,6 @@ import { renderScreen as mountScreen } from './mobile-web-shell-screen-test-harness' import { readBridgeHostMessage } from './bridge/bridge-envelope' -import { BRIDGE_ROUTE_UPDATE_ACCEPT } from './bridge/bridge-route-update' -import { BRIDGE_SAFE_AREA_ACCEPT } from './bridge/bridge-safe-area-insets' import { MobileWebShellScreen } from './MobileWebShellScreen' import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' import type { ReactTestRenderer } from 'react-test-renderer' @@ -47,16 +45,16 @@ beforeEach(() => { }) /** - * A page that pads for the system bars itself gets the whole window, like a native screen: it + * Every page pads for the system bars itself and gets the whole window, like a native screen: it * paints under both bars and its SafeAreaViews pad by the insets `init` carries. */ describe('a page that owns its safe area', () => { - async function openPage(sessionId: string, accepts: readonly string[]) { + async function openPage(sessionId: string) { dependencies.client = createFakeRpcClient() const tree = await renderScreen(readyState(sessionId)) await act(async () => { byName(tree, 'ShellViewProbe')[0]?.props.onBridgeMessage({ - nativeEvent: { json: clientFrame({ type: 'ready', accepts }) } + nativeEvent: { json: clientFrame({ type: 'ready' }) } }) }) const root = () => tree.root.find((node) => node.props.testID === 'mobile-web-shell-ready') @@ -65,73 +63,31 @@ describe('a page that owns its safe area', () => { const read = readBridgeHostMessage(json) return read.ok && read.message.type === 'init' ? [read.message.safeAreaInsets ?? null] : [] }) - const keyboard = async (height: number) => { - await act(async () => { - const ios = dependencies.platform === 'ios' - const name = - height > 0 - ? ios - ? 'keyboardWillShow' - : 'keyboardDidShow' - : ios - ? 'keyboardWillHide' - : 'keyboardDidHide' - dependencies.keyboardListeners.get(name)?.({ endCoordinates: { height } }) - }) - } - return { tree, root, initInsets, keyboard } - } - const ownedPage = (sessionId: string) => { - dependencies.pageOwnsSafeArea = true - return openPage(sessionId, [BRIDGE_ROUTE_UPDATE_ACCEPT, BRIDGE_SAFE_AREA_ACCEPT]) + return { tree, root, initInsets } } + // A `ready` that declares nothing: every page the shell serves reads the insets. + const ownedPage = openPage const WINDOW = { top: 44, right: 0, bottom: 8, left: 0 } it('draws the view edge-to-edge and hands the page the insets it now sits under', async () => { const page = await ownedPage('session-owned') - expect(page.root().props.style[1]).toEqual({ paddingTop: 0, paddingBottom: 0 }) + expect(page.root().props.style[1]).toEqual({ paddingTop: 0 }) expect(page.initInsets()).toEqual([WINDOW]) }) - it('re-sends init with no bottom inset while the keyboard ends the view', async () => { - const page = await ownedPage('session-owned-keyboard') - await page.keyboard(336) - // The view ends at the keyboard's top, so nothing of it is under the gesture bar. - expect(page.root().props.style[1]).toEqual({ paddingTop: 0, paddingBottom: 336 }) - await page.keyboard(0) - expect(page.root().props.style[1]).toEqual({ paddingTop: 0, paddingBottom: 0 }) - expect(page.initInsets()).toEqual([WINDOW, { ...WINDOW, bottom: 0 }, WINDOW]) - }) - - it('does the same on Android, where the IME strip is measured above the gesture bar', async () => { - dependencies.platform = 'android' - const page = await ownedPage('session-owned-android') - await page.keyboard(336) - expect(page.root().props.style[1]).toEqual({ paddingTop: 0, paddingBottom: 344 }) - await page.keyboard(0) - expect(page.root().props.style[1]).toEqual({ paddingTop: 0, paddingBottom: 0 }) - expect(page.initInsets()).toEqual([WINDOW, { ...WINDOW, bottom: 0 }, WINDOW]) - }) - it('gives the status bar strip to the update banner while it shows', async () => { dependencies.updateNotice = 'update-failed' const page = await ownedPage('session-owned-banner') - expect(page.root().props.style[1]).toEqual({ paddingTop: 44, paddingBottom: 0 }) + expect(page.root().props.style[1]).toEqual({ paddingTop: 44 }) expect(page.initInsets()).toEqual([{ ...WINDOW, top: 0 }]) + const dismiss = byName(page.tree, 'Pressable').find( + (node) => node.props.accessibilityLabel === 'Dismiss notice' + ) + expect(dismiss).toBeDefined() await act(async () => { - byName(page.tree, 'Pressable') - .find((node) => node.props.accessibilityLabel === 'Dismiss notice') - ?.props.onPress() + dismiss?.props.onPress() }) - expect(page.root().props.style[1]).toEqual({ paddingTop: 0, paddingBottom: 0 }) + expect(page.root().props.style[1]).toEqual({ paddingTop: 0 }) expect(page.initInsets()).toEqual([{ ...WINDOW, top: 0 }, WINDOW]) }) - - it('sends no second init to an older page that takes route updates but not insets', async () => { - const page = await openPage('session-older', [BRIDGE_ROUTE_UPDATE_ACCEPT]) - await page.keyboard(336) - await page.keyboard(0) - expect(page.root().props.style[1]).toEqual({ paddingTop: 44, paddingBottom: 8 }) - expect(page.initInsets()).toHaveLength(1) - }) }) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-state.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-state.test.ts deleted file mode 100644 index 0ea58b85b0b..00000000000 --- a/mobile/src/mobile-web-shell/mobile-web-shell-safe-area-state.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readySession, run } from './mobile-web-shell-session-test-fixtures' -import { BRIDGE_SAFE_AREA_ACCEPT } from './bridge/bridge-safe-area-insets' -import { shellPageOwnsSafeArea } from './page-document-state' - -/** Whether the shell may draw the view edge-to-edge, which only the document on screen can say. */ -describe('the page declaring it pads for the system bars', () => { - const owned = () => - run(readySession().session, { - type: 'page-ready', - reports: [], - accepts: [BRIDGE_SAFE_AREA_ACCEPT] - }) - - it('is read off the ready that declared it', () => { - expect(shellPageOwnsSafeArea(owned().session)).toBe(true) - }) - - it('is false for a page that did not declare it, which is every page before the name', () => { - const older = run(readySession().session, { type: 'page-ready', reports: [], accepts: [] }) - expect(shellPageOwnsSafeArea(older.session)).toBe(false) - }) - - it('is dropped when a replacement document starts, so the next one has to say it again', () => { - expect(shellPageOwnsSafeArea(run(owned().session, { type: 'document-started' }).session)).toBe( - false - ) - }) - - it('is not read once the generation is off screen', () => { - const failed = run(owned().session, { type: 'shell-failed', reason: 'document-load-failed' }) - expect(shellPageOwnsSafeArea(failed.session)).toBe(false) - }) -}) 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 64135cef248..2abeff8fd96 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 @@ -55,9 +55,7 @@ export type ScreenDependencies = { setScreenOptions: Mock /** Whether the session says the page is holding the device Back key. */ backClaimed: boolean - /** Whether the session says the page pads for the system bars itself. */ - pageOwnsSafeArea: boolean - /** What `Platform.OS` answers, which picks the keyboard events and the IME strip. */ + /** What `Platform.OS` answers, which picks the keyboard events. */ platform: 'ios' | 'android' reportPageBackClaim: Mock /** Whether the mounted host would take a press, which is a page that declared it takes one. */ @@ -114,7 +112,6 @@ export function createScreenDependencies(): ScreenDependencies { backHandlers: new Map(), setScreenOptions: vi.fn(), backClaimed: false, - pageOwnsSafeArea: false, platform: 'ios', reportPageBackClaim: vi.fn(), sendBackDelivers: true, @@ -154,7 +151,6 @@ export function resetScreenDependencies(dependencies: ScreenDependencies): void dependencies.setScreenOptions.mockReset() dependencies.reportPageBackClaim.mockReset() dependencies.backClaimed = false - dependencies.pageOwnsSafeArea = false dependencies.platform = 'ios' dependencies.sendBackDelivers = true dependencies.backSends = 0 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 c622c77f11d..1976c3d700f 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 @@ -180,8 +180,7 @@ export function screenModuleMocks(dependencies: ScreenDependencies) { reportPageBackClaim: dependencies.reportPageBackClaim, pageReady: dependencies.pageReady, pageFrame: dependencies.pageFrame, - backClaimed: dependencies.backClaimed, - pageOwnsSafeArea: dependencies.pageOwnsSafeArea + backClaimed: dependencies.backClaimed }) }) } diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts index 98f0df8a900..bfc8c5f385e 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts @@ -11,11 +11,6 @@ import type { MobileWebShellUpdateFailureFacts } from './mobile-web-shell-update-failure' -/** What one `ready` declared: what the page will report, and what it can be sent. */ -export type PageReadyDeclaration = { - readonly reports: readonly string[] - readonly accepts: readonly string[] -} /** * Whether the host can be asked anything right now. * @@ -218,12 +213,9 @@ export type MobileWebShellSessionEvent = /** The native view finished a document. Unstamped, like the view's failure and for the same * reason: the view exists only under the generation on screen. */ | { readonly type: 'document-loaded' } - /** - * The page said `ready` over the bridge, which is the only proof its code ran at all, carrying - * what that `ready` declared it reports and accepts. - */ - | ({ readonly type: 'page-ready' } & PageReadyDeclaration) - /** The page has a frame on screen. Only a page that declared it ever sends one. */ + /** The page said `ready` over the bridge, which is the only proof its code ran at all. */ + | { readonly type: 'page-ready' } + /** The page has a frame on screen. */ | { readonly type: 'page-painted' } /** The page is holding the device Back key, or has let it go. The host sends false on its own * for every way a document ends, so this never has to be inferred from silence. */ @@ -251,9 +243,6 @@ export type MobileWebShellSession = { /** Whether the document on screen has spoken over the bridge. Cleared by every new document, * because each one has to prove itself: the last one's word says nothing about this one. */ readonly pageReady: boolean - /** Whether this document said it would report its first paint. Cleared with `pageReady`, and - * false for every page built before the report existed. */ - readonly pageReportsPaint: boolean /** Whether this document has reported a frame on screen. Cleared with `pageReady`. */ readonly pagePainted: boolean /** @@ -264,9 +253,6 @@ export type MobileWebShellSession = { * with it, which is a key that does nothing at all. */ readonly pageBackClaimed: boolean - /** Whether this document pads for the system bars itself, so the view may go edge-to-edge. - * Cleared with `pageReady`, and false for every page built before the declaration existed. */ - readonly pageOwnsSafeArea: boolean /** The gates the current step was taken on; null until the first one arrives. */ readonly gates: MobileWebShellGates | null readonly cached: CachedGeneration | null diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.test.ts index 24f3e9a01b8..5777cb8173f 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.test.ts @@ -20,7 +20,7 @@ import { MobileWebBundleFetchError } from '../transport/mobile-web-bundle-fetch- import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import type { RpcClient } from '../transport/rpc-client' import type { GenerationStore } from './generation-store' -import { download, readManifest } from './mobile-web-shell-session-effects' +import { download, openCache, readManifest } from './mobile-web-shell-session-effects' // The mocked operations never touch the client; they only need one to exist. const CLIENT: RpcClient = { @@ -128,6 +128,27 @@ describe('the manifest read', () => { }) }) +describe('the page version the floor reads', () => { + it('is carried from the host manifest to the reducer', async () => { + doubles.manifest = () => Promise.resolve({ manifest: { ...MANIFEST, pageVersion: 4 } }) + const { events, send } = collect() + await readManifest(CLIENT, 3, send) + expect(events).toMatchObject([{ type: 'manifest-read', manifest: { pageVersion: 4 } }]) + }) + + it('is carried from the cached manifest to the generation judged against the host', async () => { + const store: GenerationStore = { + ...storeThat(null), + readActiveGeneration: async () => ({ + buildId: MANIFEST.buildId, + directory: 'd', + manifest: { ...MANIFEST, pageVersion: 4, routes: [] } + }) + } + expect((await openCache(store, 'k'))?.compat.pageVersion).toBe(4) + }) +}) + describe('the download', () => { beforeEach(() => { doubles.fetch = () => diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts index 989f56bb99b..56f3ab554d0 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-effects.ts @@ -40,7 +40,9 @@ export async function openCache( compat: { schemaVersion: active.manifest.schemaVersion, runtimeProtocolVersion: active.manifest.runtimeProtocolVersion, - minCompatibleRuntimeProtocolVersion: active.manifest.minCompatibleRuntimeProtocolVersion + minCompatibleRuntimeProtocolVersion: + active.manifest.minCompatibleRuntimeProtocolVersion, + pageVersion: active.manifest.pageVersion } } } catch { @@ -78,6 +80,7 @@ export async function readManifest( schemaVersion: manifest.schemaVersion, runtimeProtocolVersion: manifest.runtimeProtocolVersion, minCompatibleRuntimeProtocolVersion: manifest.minCompatibleRuntimeProtocolVersion, + pageVersion: manifest.pageVersion, totalBytes: manifest.totalBytes, totalAssets: manifest.assets.length, routes: manifest.routes, diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-test-fixtures.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-test-fixtures.ts index 2151597353e..17fb655dba6 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-test-fixtures.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-test-fixtures.ts @@ -41,6 +41,7 @@ export const MANIFEST_WIRE: MobileWebBundleManifestRead = { buildId: 'b'.repeat(64), minCompatibleRuntimeProtocolVersion: 2, runtimeProtocolVersion: 5, + pageVersion: 1, entrypoint: 'index.html', totalBytes: 4096, assets: [ @@ -86,6 +87,7 @@ export function manifestFacts(wire: MobileWebBundleManifestRead): MobileWebShell schemaVersion: wire.schemaVersion, runtimeProtocolVersion: wire.runtimeProtocolVersion, minCompatibleRuntimeProtocolVersion: wire.minCompatibleRuntimeProtocolVersion, + pageVersion: wire.pageVersion, totalBytes: wire.totalBytes, totalAssets: wire.assets.length, routes: wire.routes, @@ -103,7 +105,8 @@ export const CACHED: CachedGeneration = { compat: { schemaVersion: MANIFEST.schemaVersion, runtimeProtocolVersion: MANIFEST.runtimeProtocolVersion, - minCompatibleRuntimeProtocolVersion: MANIFEST.minCompatibleRuntimeProtocolVersion + minCompatibleRuntimeProtocolVersion: MANIFEST.minCompatibleRuntimeProtocolVersion, + pageVersion: MANIFEST.pageVersion } } diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts index 1cf6c0914b0..a0cf5fd0774 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts @@ -14,7 +14,6 @@ import { LINK_LOST, withoutRecord } from './mobile-web-shell-session-test-fixtures' -import { BRIDGE_PAGE_PAINTED } from './bridge/bridge-page-painted' import { shellPageFrame } from './shell-page-frame' describe('the gates decide whether a step is taken at all', () => { @@ -221,6 +220,25 @@ describe('the connected flow', () => { }) }) + it('walls a listed route whose page is older than this shell, as a desktop to update', () => { + for (const manifest of [ + { ...MANIFEST, pageVersion: 0 }, + { ...MANIFEST, pageVersion: undefined } + ]) { + const step = run(afterCacheRead(null).session, { type: 'manifest-read', manifest }) + expect(step.session.state).toEqual({ + kind: 'wall', + verdict: { + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'desktop', + pageVersion: 0, + requiredPageVersion: 1 + } + }) + } + }) + it('tells the page which routes it may keep, so it hands the rest back', () => { const step = run(afterCacheRead(null).session, { type: 'manifest-read', manifest: MANIFEST }) expect(step.session.pageRoutes).toEqual(['/h/[hostId]']) @@ -690,11 +708,7 @@ describe('the page has to speak for the document that loaded', () => { }) it('arms nothing when the page spoke first, because there is nothing left to wait for', () => { - const step = run( - readySession().session, - { type: 'page-ready', reports: [], accepts: [] }, - { type: 'document-loaded' } - ) + const step = run(readySession().session, { type: 'page-ready' }, { type: 'document-loaded' }) expect(step.effects).toEqual([]) expect(step.session.pageReady).toBe(true) }) @@ -718,7 +732,7 @@ describe('the page has to speak for the document that loaded', () => { const step = run( readySession().session, { type: 'document-loaded' }, - { type: 'page-ready', reports: [], accepts: [] }, + { type: 'page-ready' }, { type: 'page-ready-deadline' } ) expect(step.effects).toEqual([]) @@ -737,7 +751,7 @@ describe('the page has to speak for the document that loaded', () => { }) it('makes the second document prove itself, rather than riding the first one word', () => { - const spoken = run(readySession().session, { type: 'page-ready', reports: [], accepts: [] }) + const spoken = run(readySession().session, { type: 'page-ready' }) const remounted = run(spoken.session, { type: 'remounted', sessionId: 'session-two' }) expect(remounted.session.pageReady).toBe(false) expect(run(remounted.session, { type: 'document-loaded' }).effects).toEqual([ @@ -746,11 +760,7 @@ describe('the page has to speak for the document that loaded', () => { }) it('leaves a remounted document its own wait when the first one expires late', () => { - const first = run( - readySession().session, - { type: 'document-loaded' }, - { type: 'page-ready', reports: [], accepts: [] } - ) + const first = run(readySession().session, { type: 'document-loaded' }, { type: 'page-ready' }) const armed = first.session.flow const second = run( first.session, @@ -766,7 +776,7 @@ describe('the page has to speak for the document that loaded', () => { }) it('makes a freshly activated generation prove itself too', () => { - const spoken = run(readySession().session, { type: 'page-ready', reports: [], accepts: [] }) + const spoken = run(readySession().session, { type: 'page-ready' }) const reactivated = run(spoken.session, { type: 'activated', generationDirectory: CACHED.directory, @@ -784,13 +794,8 @@ describe('the page has to speak for the document that loaded', () => { * thing that says the view is worth showing is the page saying so. */ describe('the page reporting a frame on screen', () => { - it('records what the ready declared and keeps the frame covered until the report', () => { - const spoken = run(readySession().session, { - type: 'page-ready', - reports: [BRIDGE_PAGE_PAINTED], - accepts: [] - }) - expect(spoken.session.pageReportsPaint).toBe(true) + it('keeps the frame covered past ready until the report', () => { + const spoken = run(readySession().session, { type: 'page-ready' }) expect(spoken.session.pagePainted).toBe(false) expect(shellPageFrame(spoken.session)).toBe('unpainted') const painted = run(spoken.session, { type: 'page-painted' }) @@ -798,42 +803,15 @@ describe('the page reporting a frame on screen', () => { expect(shellPageFrame(painted.session)).toBe('painted') }) - it('new shell, old page: ignores an undeclared report and uncovers on ready', () => { - const spoken = run(readySession().session, { type: 'page-ready', reports: [], accepts: [] }) - const step = run(spoken.session, { type: 'page-painted' }) - expect(step.session.pagePainted).toBe(false) - // Uncovered anyway: `ready` is the newest word a page built before the report can say. - expect(shellPageFrame(step.session)).toBe('painted') - }) - - it('re-reads the declaration on every ask, because a reload asks again', () => { - const declared = run(readySession().session, { - type: 'page-ready', - reports: [BRIDGE_PAGE_PAINTED], - accepts: [] - }) - const reloaded = run(declared.session, { type: 'page-ready', reports: [], accepts: [] }) - expect(reloaded.session.pageReportsPaint).toBe(false) - }) - it('makes a remounted document report its own frame', () => { - const painted = run( - readySession().session, - { type: 'page-ready', reports: [BRIDGE_PAGE_PAINTED], accepts: [] }, - { type: 'page-painted' } - ) + const painted = run(readySession().session, { type: 'page-ready' }, { type: 'page-painted' }) const remounted = run(painted.session, { type: 'remounted', sessionId: 'session-two' }) expect(remounted.session.pagePainted).toBe(false) - expect(remounted.session.pageReportsPaint).toBe(false) expect(shellPageFrame(remounted.session)).toBe('unpainted') }) it('makes a freshly activated generation report its own frame', () => { - const painted = run( - readySession().session, - { type: 'page-ready', reports: [BRIDGE_PAGE_PAINTED], accepts: [] }, - { type: 'page-painted' } - ) + const painted = run(readySession().session, { type: 'page-ready' }, { type: 'page-painted' }) const reactivated = run(painted.session, { type: 'activated', generationDirectory: CACHED.directory, @@ -843,24 +821,15 @@ describe('the page reporting a frame on screen', () => { elapsedMs: 9 }) expect(reactivated.session.pagePainted).toBe(false) - expect(reactivated.session.pageReportsPaint).toBe(false) }) it('makes the document that replaced a painted one inside this mount report its own frame', () => { - const painted = run( - readySession().session, - { type: 'page-ready', reports: [BRIDGE_PAGE_PAINTED], accepts: [] }, - { type: 'page-painted' } - ) + const painted = run(readySession().session, { type: 'page-ready' }, { type: 'page-painted' }) expect(shellPageFrame(painted.session)).toBe('painted') // No new session and no new generation: the view reloaded under the one already on screen. const restarted = run(painted.session, { type: 'document-started' }) expect(restarted.session.pagePainted).toBe(false) - const reasked = run(restarted.session, { - type: 'page-ready', - reports: [BRIDGE_PAGE_PAINTED], - accepts: [] - }) + const reasked = run(restarted.session, { type: 'page-ready' }) expect(shellPageFrame(reasked.session)).toBe('unpainted') expect(shellPageFrame(run(reasked.session, { type: 'page-painted' }).session)).toBe('painted') }) @@ -869,11 +838,7 @@ describe('the page reporting a frame on screen', () => { const loaded = run(readySession().session, { type: 'document-loaded' }) expect(loaded.effects).toEqual([{ kind: 'await-page-ready' }]) const armed = loaded.session.flow - const spoken = run(loaded.session, { - type: 'page-ready', - reports: [BRIDGE_PAGE_PAINTED], - accepts: [] - }) + const spoken = run(loaded.session, { type: 'page-ready' }) const restarted = run(spoken.session, { type: 'document-started' }) // The replacement is still loading and has said nothing, which is exactly what the retired // document's deadline reads as a document that never loaded. @@ -888,9 +853,9 @@ describe('the page reporting a frame on screen', () => { it('keeps the frame of a document that repeats its own handshake', () => { const painted = run( readySession().session, - { type: 'page-ready', reports: [BRIDGE_PAGE_PAINTED], accepts: [] }, + { type: 'page-ready' }, { type: 'page-painted' }, - { type: 'page-ready', reports: [BRIDGE_PAGE_PAINTED], accepts: [] } + { type: 'page-ready' } ) // The document never restarted, so its frame is still the one on screen. expect(painted.session.pagePainted).toBe(true) @@ -900,8 +865,9 @@ describe('the page reporting a frame on screen', () => { it('records nothing from a page whose generation is no longer on screen', () => { const failed = run(readySession().session, { type: 'shell-failed', - reason: 'render-process-gone' + reason: 'document-load-failed' }) + expect(failed.session.state.kind).not.toBe('ready') expect(run(failed.session, { type: 'page-painted' }).session.pagePainted).toBe(false) }) }) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts index 5a0a25b7db6..595e1d92dfd 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts @@ -39,10 +39,8 @@ export function createMobileWebShellSession(routePathname: string): MobileWebShe retriedOnce: false, remountedOnce: false, pageReady: false, - pageReportsPaint: false, pagePainted: false, pageBackClaimed: false, - pageOwnsSafeArea: false, gates: null, cached: null, updateNotice: null, diff --git a/mobile/src/mobile-web-shell/page-document-state.ts b/mobile/src/mobile-web-shell/page-document-state.ts index 935f201d346..e2405f24591 100644 --- a/mobile/src/mobile-web-shell/page-document-state.ts +++ b/mobile/src/mobile-web-shell/page-document-state.ts @@ -1,5 +1,3 @@ -import { BRIDGE_PAGE_PAINTED } from './bridge/bridge-page-painted' -import { BRIDGE_SAFE_AREA_ACCEPT } from './bridge/bridge-safe-area-insets' import type { MobileWebShellSession, MobileWebShellSessionEvent @@ -8,10 +6,8 @@ import type { /** Everything one document told the shell about itself, which the next one has to say again. */ export const CLEAR_PAGE_DOCUMENT_STATE = { pageReady: false, - pageReportsPaint: false, pagePainted: false, - pageBackClaimed: false, - pageOwnsSafeArea: false + pageBackClaimed: false } as const /** The three things a document reports about itself, as the reducer receives them. */ @@ -25,7 +21,7 @@ export type PageDocumentEvent = Extract< * view exists only under the generation on screen. */ export function pageDocumentStatePatch( - session: Pick, + session: Pick, event: PageDocumentEvent ): Partial { if (session.state.kind !== 'ready') { @@ -35,25 +31,8 @@ export function pageDocumentStatePatch( return { pageBackClaimed: event.claimed } } if (event.type === 'page-ready') { - // Re-read on every ask rather than latched: a document that reloads inside this mount asks - // again, and it is the newest ask that says whether a paint report is coming. The key goes with - // it: the host drops the claim on the same `ready`, so a page that still holds one re-claims. - return { - pageReady: true, - pageReportsPaint: event.reports.includes(BRIDGE_PAGE_PAINTED), - pageBackClaimed: false, - pageOwnsSafeArea: event.accepts.includes(BRIDGE_SAFE_AREA_ACCEPT) - } + // The host drops the claim on the same `ready`, so a page that still holds one re-claims. + return { pageReady: true, pageBackClaimed: false } } - // Kept off a page that never said it would report: acting on an unasked-for frame would make - // the wait depend on a name arriving instead of on a claim the page made. - return session.pageReportsPaint ? { pagePainted: true } : {} -} - -/** Whether the shell may draw the view edge-to-edge: gated on `ready` like the Back claim, so a - * declaration never outlives the document on screen. */ -export function shellPageOwnsSafeArea( - session: Pick -): boolean { - return session.state.kind === 'ready' && session.pageOwnsSafeArea + return { pagePainted: true } } diff --git a/mobile/src/mobile-web-shell/page-safe-area-insets.test.ts b/mobile/src/mobile-web-shell/page-safe-area-insets.test.ts index 76877567b31..e0bd1305405 100644 --- a/mobile/src/mobile-web-shell/page-safe-area-insets.test.ts +++ b/mobile/src/mobile-web-shell/page-safe-area-insets.test.ts @@ -5,22 +5,10 @@ const WINDOW = { top: 52, right: 12, bottom: 24, left: 12 } describe('the insets the page pads for', () => { it('is the window insets while nothing of the shell stands over the view', () => { - expect(pageSafeAreaInsets({ insets: WINDOW, keyboardInset: 0, topCovered: false })).toEqual( - WINDOW - ) - }) - - it('has no bottom while the keyboard ends the view above the gesture bar', () => { - expect(pageSafeAreaInsets({ insets: WINDOW, keyboardInset: 360, topCovered: false })).toEqual({ - ...WINDOW, - bottom: 0 - }) + expect(pageSafeAreaInsets({ insets: WINDOW, topCovered: false })).toEqual(WINDOW) }) it('has no top while the shell banner takes the status bar strip', () => { - expect(pageSafeAreaInsets({ insets: WINDOW, keyboardInset: 0, topCovered: true })).toEqual({ - ...WINDOW, - top: 0 - }) + expect(pageSafeAreaInsets({ insets: WINDOW, topCovered: true })).toEqual({ ...WINDOW, top: 0 }) }) }) diff --git a/mobile/src/mobile-web-shell/page-safe-area-insets.ts b/mobile/src/mobile-web-shell/page-safe-area-insets.ts index 3b9666baca9..71da590370b 100644 --- a/mobile/src/mobile-web-shell/page-safe-area-insets.ts +++ b/mobile/src/mobile-web-shell/page-safe-area-insets.ts @@ -4,26 +4,24 @@ import type { BridgeSafeAreaInsets } from './bridge/bridge-safe-area-insets' /** * The part of an edge-to-edge WebView that sits under a system bar, which is what the page pads for. * - * Not the window's insets as they are: with the keyboard up the shell already ends the view at the - * keyboard's top, so nothing of the page is under the gesture bar, and a banner the shell draws above - * the view takes the status bar strip itself. + * The window's insets, as a native screen reads them with the keyboard covering it, except where + * the shell's banner stands above the view and takes the status bar strip itself. */ export function pageSafeAreaInsets(input: { insets: BridgeSafeAreaInsets - keyboardInset: number topCovered: boolean }): BridgeSafeAreaInsets { const { insets } = input return { top: input.topCovered ? 0 : insets.top, right: insets.right, - bottom: input.keyboardInset > 0 ? 0 : insets.bottom, + bottom: insets.bottom, left: insets.left } } -/** Hands moved insets to the page over the re-sent `init` a pane move takes: the keyboard, a - * rotation, or the shell's banner. Keyed on the four numbers, so a render moves nothing. */ +/** Hands moved insets to the page over the re-sent `init` a pane move takes: a rotation or the + * shell's banner. Keyed on the four numbers, so a render moves nothing. */ export function usePublishedSafeAreaInsets( publish: (insets: BridgeSafeAreaInsets) => void, insets: BridgeSafeAreaInsets 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 new file mode 100644 index 00000000000..a1d40ccf49a --- /dev/null +++ b/mobile/src/mobile-web-shell/shell-older-page-paths-census.test.ts @@ -0,0 +1,89 @@ +/** + * 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-page-frame.test.ts b/mobile/src/mobile-web-shell/shell-page-frame.test.ts index 243f5763d77..8c4d7b5b6d8 100644 --- a/mobile/src/mobile-web-shell/shell-page-frame.test.ts +++ b/mobile/src/mobile-web-shell/shell-page-frame.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { BRIDGE_PAGE_PAINTED } from './bridge/bridge-page-painted' import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' import { shellPageFrame } from './shell-page-frame' @@ -12,18 +11,8 @@ const READY: MobileWebShellSessionState = { elapsedMs: 1 } -function frame(patch: { - state?: MobileWebShellSessionState - pageReady?: boolean - pageReportsPaint?: boolean - pagePainted?: boolean -}) { - return shellPageFrame({ - state: patch.state ?? READY, - pageReady: patch.pageReady ?? false, - pageReportsPaint: patch.pageReportsPaint ?? false, - pagePainted: patch.pagePainted ?? false - }) +function frame(patch: { state?: MobileWebShellSessionState; pagePainted?: boolean }) { + return shellPageFrame({ state: patch.state ?? READY, pagePainted: patch.pagePainted ?? false }) } describe('how long the shell keeps its own frame up', () => { @@ -38,33 +27,13 @@ describe('how long the shell keeps its own frame up', () => { } }) - it('covers a mounted view that has said nothing, which is the whole of the page boot', () => { + it('covers a mounted view until the page reports a frame, handshake or not', () => { + // `ready` is posted before the tree is built, so the view is mounted, empty and showing the + // surface behind it for every frame between the two. expect(frame({})).toBe('unpainted') }) - it('keeps covering a page that has handshaken and not yet painted', () => { - // The gap this exists for: `ready` is posted before the tree is built, so the view is mounted, - // empty and showing the surface behind it for every frame between the two. - expect(frame({ pageReady: true, pageReportsPaint: true })).toBe('unpainted') - }) - - it('new shell, new page: uncovers on the page reporting a frame', () => { - expect(frame({ pageReady: true, pageReportsPaint: true, pagePainted: true })).toBe('painted') - }) - - it('new shell, old page: uncovers on ready, the newest word that page will ever say', () => { - // A generation served by a desktop built before the report exists. Waiting on a frame it - // cannot send would hide a working workspace for the life of the document. - expect(frame({ pageReady: true, pageReportsPaint: false })).toBe('painted') - }) - - it('reads the declaration off the list the page sent, not off the shell', () => { - // The name is what the page puts in `ready.reports`, so the frame follows that list and not a - // flag the shell set: a list without it is a page whose newest word is `ready`. - const declared = [BRIDGE_PAGE_PAINTED].includes(BRIDGE_PAGE_PAINTED) - expect(frame({ pageReady: true, pageReportsPaint: declared })).toBe('unpainted') - expect( - frame({ pageReady: true, pageReportsPaint: ['other'].includes(BRIDGE_PAGE_PAINTED) }) - ).toBe('painted') + it('uncovers on the page reporting a frame', () => { + expect(frame({ pagePainted: true })).toBe('painted') }) }) diff --git a/mobile/src/mobile-web-shell/shell-page-frame.ts b/mobile/src/mobile-web-shell/shell-page-frame.ts index 02d870a0f48..45094d9d0a0 100644 --- a/mobile/src/mobile-web-shell/shell-page-frame.ts +++ b/mobile/src/mobile-web-shell/shell-page-frame.ts @@ -7,19 +7,12 @@ import type { MobileWebShellSession } from './mobile-web-shell-session-contract' */ export type ShellPageFrame = 'pending' | 'unpainted' | 'painted' -/** - * Bounded by the page's declaration, never by a timer. A page that declared none is one served by - * a desktop older than the report, and `ready` is the newest thing it will ever say: waiting on a - * word it cannot speak would hide a working workspace for the life of the document. - */ +/** Up until the page reports a frame of its own; every page the shell serves reports one. */ export function shellPageFrame( - session: Pick + session: Pick ): ShellPageFrame { if (session.state.kind !== 'ready') { return 'pending' } - if (session.pagePainted) { - return 'painted' - } - return session.pageReportsPaint || !session.pageReady ? 'unpainted' : 'painted' + return session.pagePainted ? 'painted' : 'unpainted' } diff --git a/mobile/src/mobile-web-shell/shell-view-keyboard-accessory.test.ts b/mobile/src/mobile-web-shell/shell-view-keyboard-accessory.test.ts new file mode 100644 index 00000000000..56e542fd40a --- /dev/null +++ b/mobile/src/mobile-web-shell/shell-view-keyboard-accessory.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * The iOS shell's keyboard, read off the Swift view: a property of a live WKWebView, so what is + * checkable here is that the view installs the fix, and the simulator proof measures its effect. + * + * iPhone 17 simulator, measured: WKWebView's form accessory bar (up, down, done) rides on the + * keyboard, and the keyboard event's height counts it, so the page's terminal lifted 376 pt against + * native's 274 and its dock stood 52 pt above the bar. + */ +const SHELL = join(import.meta.dirname, '..', '..', 'modules', 'orca-mobile-web-shell', 'ios') + +describe("the iOS WebView's keyboard", () => { + it('carries no form accessory bar, so the keyboard is the height a native screen reads', () => { + const view = readFileSync(join(SHELL, 'MobileWebShellView.swift'), 'utf8') + expect(view).toContain('hideKeyboardAccessoryBar(of: webView)') + const accessory = readFileSync(join(SHELL, 'MobileWebShellKeyboardAccessory.swift'), 'utf8') + expect(accessory).toContain('inputAccessoryView') + }) + + it('does not scroll the page to reveal a focused field, which the page lifts itself', () => { + // iPhone 17 simulator: focusing the page's commit message scrolled the whole document up, the + // header off screen and the bar 384 pt above the keyboard (shot ios-43). + const view = readFileSync(join(SHELL, 'MobileWebShellView.swift'), 'utf8') + expect(view).toContain('ignoreKeyboardNotifications(in: webView)') + const accessory = readFileSync(join(SHELL, 'MobileWebShellKeyboardAccessory.swift'), 'utf8') + expect(accessory).toContain('keyboardWillChangeFrameNotification') + }) +}) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts index 82ed511a223..67bc07fba2c 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts @@ -151,6 +151,7 @@ function Harness(props: { // Built inline on every render, as a caller writes it: the host is not rebuilt for it. route: { pathname: '/h/host-1' }, safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + keyboardInset: 0, pageRoutes: ['/h/[hostId]'], pageRouteGrants: [{ pathname: '/h/[hostId]', grants: ['navigate', 'storage'] }], routeGrants: [ @@ -338,7 +339,7 @@ describe('the bridge channel', () => { it("hands the page's first paint to the caller that owns the cover over the view", async () => { const mounted = await mount(readyState('session-one')) - await mounted.deliver(clientFrame({ type: 'ready', reports: [BRIDGE_PAGE_PAINTED] })) + await mounted.deliver(clientFrame({ type: 'ready' })) expect(mounted.probe.paints).toBe(0) await mounted.deliver(clientFrame({ type: 'notify', name: BRIDGE_PAGE_PAINTED })) expect(mounted.probe.paints).toBe(1) @@ -657,13 +658,13 @@ describe('client changes', () => { * * A client swapped under a live page is not a new document: the WebView stays mounted, the * session id does not move, and the page neither handshakes again nor hears that anything - * happened. What it declared and what it is holding therefore belong to the session, the way + * happened. What it is holding therefore belongs to the session, the way * `sessionEstablished` already does — a rebuilt host that started over would answer every press * with "I cannot deliver this" and pop the screen out from under an open sheet. */ it('keeps delivering Back to an open sheet after the host is rebuilt under it', async () => { const mounted = await mount(readyState('session-one')) - await mounted.deliver(clientFrame({ type: 'ready', accepts: [BRIDGE_BACK_FRAME] })) + await mounted.deliver(clientFrame({ type: 'ready' })) await mounted.deliver( clientFrame({ type: 'notify', name: BRIDGE_BACK_CLAIM_NOTIFY, claimed: true }) ) @@ -681,47 +682,17 @@ describe('client changes', () => { expect(mounted.frames('session-one').slice(before)).toEqual([{ v: 1, type: BRIDGE_BACK_FRAME }]) }) - /** The other half of the same seed: a page that never said it takes a press is still one the - * rebuilt host will not send to. An empty declaration is a declaration. */ - it('sends nothing after a rebuild to a page that never declared the frame', async () => { - const mounted = await mount(readyState('session-one')) - await mounted.deliver(clientFrame({ type: 'ready' })) - doubles.client = createFakeRpcClient() - await mounted.update(readyState('session-one')) - const before = mounted.frames('session-one').length - expect(mounted.probe.view?.sendBack()).toBe(false) - expect(mounted.frames('session-one')).toHaveLength(before) - }) - - /** The seed is the session's, not the hook's. A different session id is a different document, - * which has declared nothing and is holding nothing until it says so itself. */ - it('carries nothing into a host built for a different session', async () => { - // Both mounts stand for an established session, so the rebuilt host's own pre-handshake gate - // is already open and the declaration is the only thing left that can refuse the press. - const mounted = await mount(readyState('session-one'), true) - await mounted.deliver(clientFrame({ type: 'ready', accepts: [BRIDGE_BACK_FRAME] })) - await mounted.deliver( - clientFrame({ type: 'notify', name: BRIDGE_BACK_CLAIM_NOTIFY, claimed: true }) - ) - // The control: the same rebuild inside the session does keep delivering. - doubles.client = createFakeRpcClient() - await mounted.update(readyState('session-one')) - expect(mounted.probe.view?.sendBack()).toBe(true) - await mounted.update(readyState('session-two')) - expect(mounted.probe.view?.sendBack()).toBe(false) - }) - /** A new document inside the same session still starts over: its own `ready` is what the host * reads, and the seed is not a latch. */ it('drops the carried claim when the next document says ready', async () => { const mounted = await mount(readyState('session-one')) - await mounted.deliver(clientFrame({ type: 'ready', accepts: [BRIDGE_BACK_FRAME] })) + await mounted.deliver(clientFrame({ type: 'ready' })) await mounted.deliver( clientFrame({ type: 'notify', name: BRIDGE_BACK_CLAIM_NOTIFY, claimed: true }) ) doubles.client = createFakeRpcClient() await mounted.update(readyState('session-one')) - await mounted.deliver(clientFrame({ type: 'ready', accepts: [BRIDGE_BACK_FRAME] })) + await mounted.deliver(clientFrame({ type: 'ready' })) expect(mounted.probe.backClaims).toEqual([true, false]) }) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts index 2489e1ef564..3a55096ac32 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts @@ -14,10 +14,7 @@ import type { BridgeSessionBack } from './bridge-host-back' import type { BridgeNavigateBackOutcome } from './bridge-host-contract' import type { BridgeNativeVerb } from './bridge/bridge-native-verbs' import type { BridgeErrorCapture } from './bridge/bridge-error-capture' -import type { - MobileWebShellSessionState, - PageReadyDeclaration -} from './mobile-web-shell-session-contract' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' import type { PageHostSnapshot } from './use-page-host-snapshot' import type { PageStorageForInit } from './page-storage-keys' @@ -62,6 +59,8 @@ export type MobileWebShellBridgeView = { readonly publishRoute: (route: BridgeInitRoute) => void /** Hands the mounted host moved safe-area insets; dropped with no host, as a route is. */ readonly publishSafeAreaInsets: (insets: BridgeSafeAreaInsets) => void + /** Hands the mounted host a moved keyboard height; dropped with no host, as the insets are. */ + readonly publishKeyboardInset: (height: number) => void /** * Hands the mounted host one Back press. False when there is no host, or when the document it * serves never said it takes one — the caller then leaves the key to the navigator. @@ -86,6 +85,8 @@ export type MobileWebShellBridgeArgs = { route: BridgeInitRoute /** What the first `init` of a new host carries; later moves go through `publishSafeAreaInsets`. */ safeAreaInsets: BridgeSafeAreaInsets + /** What the first `init` of a new host carries; later moves go through `publishKeyboardInset`. */ + keyboardInset: number /** The route patterns the page keeps for itself; everything else comes back as `navigate`. */ pageRoutes: readonly string[] pageRouteGrants: readonly { pathname: string; grants: readonly string[] }[] @@ -113,10 +114,9 @@ export type MobileWebShellBridgeArgs = { onStorageWrite: (key: string, value: string | null) => void /** The page could not render the generation on screen. Reported, never recovered from here. */ onPageFault: (error: BridgeErrorCapture) => void - /** The page asked for a session, and what it declared it reports. Reported so the screen can - * stop waiting for it, and so it knows whether a paint report is coming. */ - onPageReady: (ready: PageReadyDeclaration) => void - /** The page has a frame on screen, from a page that said it would report one. */ + /** The page asked for a session. Reported so the screen can stop waiting for it. */ + onPageReady: () => void + /** The page has a frame on screen. */ onPagePainted: () => void /** The page is holding the device Back key, or has let it go. False arrives on its own for * every way a document ends, so no claim outlives the page that made it. */ @@ -195,6 +195,7 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW sessionId, route: latest.route, safeAreaInsets: latest.safeAreaInsets, + keyboardInset: latest.keyboardInset, pageRoutes: latest.pageRoutes, pageRouteGrants: latest.pageRouteGrants, routeGrants: latest.routeGrants, @@ -222,7 +223,7 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW onRouteParamClear: (param, value) => argsRef.current.onRouteParamClear(param, value), onRouteRefused: (issue) => argsRef.current.onRouteRefused(issue), onBinaryFramesDropped: (total) => argsRef.current.onBinaryFramesDropped(total), - onPageReady: (ready) => argsRef.current.onPageReady(ready), + onPageReady: () => argsRef.current.onPageReady(), onPagePainted: () => argsRef.current.onPagePainted(), onPageBackClaim: (claimed) => argsRef.current.onPageBackClaim(claimed) }) @@ -283,6 +284,15 @@ export function useMobileWebShellBridge(args: MobileWebShellBridgeArgs): MobileW }, [buildId, client, sessionId, snapshot] ), + publishKeyboardInset: useCallback( + (height: number) => { + const mounted = hostRef.current + if (mounted !== null && mounted.sessionId === sessionId) { + mounted.host.publishKeyboardInset(height) + } + }, + [buildId, client, sessionId, snapshot] + ), // Fenced on the session the way inbound frames are, and answering false rather than throwing // when there is no host: the caller is a hardware key handler, and a press it cannot forward // is one the navigator has to be left to answer. diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts index 0787874b441..f9ab6443788 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts @@ -41,6 +41,7 @@ const doubles = vi.hoisted((): Doubles => { buildId: 'b'.repeat(64), minCompatibleRuntimeProtocolVersion: 2, runtimeProtocolVersion: 5, + pageVersion: 1, entrypoint: 'index.html', totalBytes: 2048, assets: [ @@ -210,7 +211,7 @@ type Mounted = { /** Whether the page had spoken, as every render of the hook reported it. */ handshakes: () => readonly boolean[] documentLoaded: () => void - pageReady: (reports?: readonly string[]) => void + pageReady: () => void timers: ReturnType } @@ -219,7 +220,7 @@ async function mount(store: GenerationStore): Promise { const handle: { retry: () => void documentLoaded: () => void - pageReady: (ready: { reports: readonly string[]; accepts: readonly string[] }) => void + pageReady: () => void states: MobileWebShellSessionState[] handshakes: boolean[] } = { @@ -262,7 +263,7 @@ async function mount(store: GenerationStore): Promise { states: () => handle.states, handshakes: () => handle.handshakes, documentLoaded: () => handle.documentLoaded(), - pageReady: (reports: readonly string[] = []) => handle.pageReady({ reports, accepts: [] }), + pageReady: () => handle.pageReady(), timers } } diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts index da22cd69a2e..57eea752065 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts @@ -21,10 +21,8 @@ import type { MobileWebShellSessionEffect, MobileWebShellSessionEvent, MobileWebShellSessionState, - MobileWebShellUpdateNotice, - PageReadyDeclaration + MobileWebShellUpdateNotice } from './mobile-web-shell-session-contract' -import { shellPageOwnsSafeArea } from './page-document-state' export type MobileWebShellSessionView = { readonly state: MobileWebShellSessionState @@ -42,10 +40,9 @@ export type MobileWebShellSessionView = { readonly reportDocumentStarted: () => void /** The native view finished a document; starts the wait for the page's first word. */ readonly reportDocumentLoaded: () => void - /** The page spoke over the bridge; ends that wait, whichever of the two arrived first. Carries - * what that `ready` declared it reports, which is what says whether a paint is coming. */ - readonly reportPageReady: (ready: PageReadyDeclaration) => void - /** The page has a frame on screen. Ignored for a page that never said it would report one. */ + /** The page spoke over the bridge; ends that wait, whichever of the two arrived first. */ + readonly reportPageReady: () => void + /** The page has a frame on screen. */ readonly reportPagePainted: () => void /** The page took the device Back key, or let it go. */ readonly reportPageBackClaim: (claimed: boolean) => void @@ -62,9 +59,6 @@ export type MobileWebShellSessionView = { /** Whether the shell should take Back off the navigator. Projected for the same reason as * `pageReady`: a claim moves nothing else, so no other value would re-render to carry it. */ readonly backClaimed: boolean - /** Whether the document on screen pads for the system bars itself. Projected for the same - * reason as `backClaimed`. */ - readonly pageOwnsSafeArea: boolean } /** @@ -95,9 +89,6 @@ export function useMobileWebShellSession(args: { const [pageReady, setPageReady] = useState(sessionRef.current.pageReady) const [pageFrame, setPageFrame] = useState(() => shellPageFrame(sessionRef.current)) const [backClaimed, setBackClaimed] = useState(() => shellPageBackClaimed(sessionRef.current)) - const [pageOwnsSafeArea, setPageOwnsSafeArea] = useState(() => - shellPageOwnsSafeArea(sessionRef.current) - ) const hostKey = useMemo(() => deriveHostCacheKey(hostId), [hostId]) const startedAtRef = useRef(runtime.now()) // Bumped by anything that invalidates work in flight; every dispatch out of an effect checks it. @@ -121,7 +112,6 @@ export function useMobileWebShellSession(args: { setPageReady(stepped.session.pageReady) setPageFrame(shellPageFrame(stepped.session)) setBackClaimed(shellPageBackClaimed(stepped.session)) - setPageOwnsSafeArea(shellPageOwnsSafeArea(stepped.session)) for (const effect of stepped.effects) { // Every effect of a step belongs to the flow that step produced, and its result carries that // number back, so a flow the session has since restarted reports into nothing. @@ -281,12 +271,9 @@ export function useMobileWebShellSession(args: { dispatch(epochRef.current, { type: 'document-loaded' }) }, [dispatch]) - const reportPageReady = useCallback( - (ready: PageReadyDeclaration) => { - dispatch(epochRef.current, { type: 'page-ready', ...ready }) - }, - [dispatch] - ) + const reportPageReady = useCallback(() => { + dispatch(epochRef.current, { type: 'page-ready' }) + }, [dispatch]) const reportPagePainted = useCallback(() => { dispatch(epochRef.current, { type: 'page-painted' }) @@ -314,7 +301,6 @@ export function useMobileWebShellSession(args: { reportPageReady, reportPagePainted, reportPageBackClaim, - backClaimed, - pageOwnsSafeArea + backClaimed } } diff --git a/mobile/src/mobile-web-shell/use-shell-page-back.ts b/mobile/src/mobile-web-shell/use-shell-page-back.ts index dd1ac0ac7b1..ecf8f145f20 100644 --- a/mobile/src/mobile-web-shell/use-shell-page-back.ts +++ b/mobile/src/mobile-web-shell/use-shell-page-back.ts @@ -9,7 +9,7 @@ import { BackHandler, Platform } from 'react-native' * and posted; iOS has none, so what is taken away is the stack's swipe-back gesture — a swipe while * a sheet is open would leave the screen with the sheet still on it. * - * Nothing is registered without a live claim, so a page too old to make one, a shell whose page has + * Nothing is registered without a live claim, so a page holding nothing, a shell whose page has * faulted, and a session between documents all keep today's behaviour: the navigator pops. */ export function useShellPageBack(args: { diff --git a/mobile/src/platform/host-os.ts b/mobile/src/platform/host-os.ts new file mode 100644 index 00000000000..da58fbe0d1e --- /dev/null +++ b/mobile/src/platform/host-os.ts @@ -0,0 +1,8 @@ +import { Platform } from 'react-native' + +/** The OS a screen's keyboard and inset arithmetic is for. Natively, the one it runs on. */ +export type HostOs = 'ios' | 'android' | 'windows' | 'macos' | 'web' + +export function hostOs(): HostOs { + return Platform.OS +} diff --git a/mobile/src/platform/host-os.web.test.ts b/mobile/src/platform/host-os.web.test.ts new file mode 100644 index 00000000000..fcc3d446f36 --- /dev/null +++ b/mobile/src/platform/host-os.web.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from 'vitest' +import { hostOs } from './host-os.web' + +function agent(userAgent: string, platform = '', maxTouchPoints = 0) { + Object.defineProperty(navigator, 'userAgent', { value: userAgent, configurable: true }) + Object.defineProperty(navigator, 'platform', { value: platform, configurable: true }) + Object.defineProperty(navigator, 'maxTouchPoints', { value: maxTouchPoints, configurable: true }) +} + +afterEach(() => agent('')) + +describe("the page's host OS", () => { + it('reads the phone off the WebView user agent', () => { + agent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148' + ) + expect(hostOs()).toBe('ios') + agent( + 'Mozilla/5.0 (Linux; Android 17; sdk_gphone64_arm64 Build/CP1A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0 Mobile Safari/537.36' + ) + expect(hostOs()).toBe('android') + }) + + it('reads an iPad that still names itself in the user agent', () => { + agent( + 'Mozilla/5.0 (iPad; CPU OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148' + ) + expect(hostOs()).toBe('ios') + }) + + it('takes an iPad, which reports a Mac, by its touch points', () => { + agent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 'MacIntel', 5) + expect(hostOs()).toBe('ios') + agent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 'MacIntel', 0) + expect(hostOs()).toBe('web') + }) +}) diff --git a/mobile/src/platform/host-os.web.ts b/mobile/src/platform/host-os.web.ts new file mode 100644 index 00000000000..755cf3a2ace --- /dev/null +++ b/mobile/src/platform/host-os.web.ts @@ -0,0 +1,19 @@ +import type { HostOs } from './host-os' + +export type { HostOs } from './host-os' + +/** + * Web sibling: `Platform.OS` is `web` inside the shell's page, but the keyboard it lifts over is the + * phone's, whose height the shell reports in that OS's own terms. So the page answers the phone's + * OS from the WebView's user agent. iPadOS reports a Mac, which only its touch points give away. + */ +export function hostOs(): HostOs { + const agent = globalThis.navigator?.userAgent ?? '' + if (/iP(ad|hone|od)/.test(agent)) { + return 'ios' + } + if (globalThis.navigator?.platform === 'MacIntel' && globalThis.navigator.maxTouchPoints > 1) { + return 'ios' + } + return agent.includes('Android') ? 'android' : 'web' +} diff --git a/mobile/src/platform/keyboard-geometry-host-os-census.test.ts b/mobile/src/platform/keyboard-geometry-host-os-census.test.ts new file mode 100644 index 00000000000..81b7259395d --- /dev/null +++ b/mobile/src/platform/keyboard-geometry-host-os-census.test.ts @@ -0,0 +1,69 @@ +/** + * 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 index 25c90d0d1ee..b3bee07b5f4 100644 --- a/mobile/src/platform/keyboard-geometry-owner-census.test.ts +++ b/mobile/src/platform/keyboard-geometry-owner-census.test.ts @@ -15,8 +15,8 @@ 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; it subscribes only while the - * seam reports a keyboard, which on the page is never, so there it cannot wait forever. */ +/** 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 => diff --git a/mobile/src/platform/keyboard-occlusion.test.tsx b/mobile/src/platform/keyboard-occlusion.test.tsx index 526da87f12d..c388c775de2 100644 --- a/mobile/src/platform/keyboard-occlusion.test.tsx +++ b/mobile/src/platform/keyboard-occlusion.test.tsx @@ -47,14 +47,12 @@ vi.mock('react-native', () => ({ import { currentSoftKeyboardHeight, subscribeSoftKeyboard, - useKeyboardAvoidingPadding, useKeyboardOcclusion, useSoftKeyboard, type SoftKeyboardState } from './keyboard-occlusion' let lift = 0 -let padding = 0 let keyboardState: SoftKeyboardState = { height: 0, visible: false } function Harness(): null { @@ -62,12 +60,6 @@ function Harness(): null { return null } -/** Separate, so the padding case measures the padding hook's own subscriptions and nothing else. */ -function PaddingHarness(): null { - padding = useKeyboardAvoidingPadding() - return null -} - function StateHarness(): null { keyboardState = useSoftKeyboard() return null @@ -94,7 +86,6 @@ describe('the keyboard the phone reports', () => { keyboard.addListenerCalls = 0 keyboard.metrics = undefined lift = 0 - padding = 0 keyboardState = { height: 0, visible: false } }) @@ -137,17 +128,6 @@ describe('the keyboard the phone reports', () => { expect(keyboard.removed.sort()).toEqual(['keyboardWillHide', 'keyboardWillShow']) }) - it('asks a phone for no composer padding, because KeyboardAvoidingView already moved it', async () => { - // Rendered, not called: a hook read outside a component measures whatever the module does at - // the top of its body and nothing its effects do, which is where a subscription would live. - // And it subscribes to nothing doing it, so a composer that calls this renders as often as it - // does today — which is what makes adding the call to a shared component safe. - await mountComponent(PaddingHarness) - expect(padding).toBe(0) - expect(keyboard.addListenerCalls).toBe(0) - expect(keyboard.listeners.size).toBe(0) - }) - it('answers both facts from one subscription, so a screen wanting each pays for one', async () => { // The session screen reads the height to lift its dock and the flag to hold off the terminal // refit. Two hooks would mean two listener pairs and two renders per keyboard event. diff --git a/mobile/src/platform/keyboard-occlusion.ts b/mobile/src/platform/keyboard-occlusion.ts index 6b0fc0deb2b..aa952871d98 100644 --- a/mobile/src/platform/keyboard-occlusion.ts +++ b/mobile/src/platform/keyboard-occlusion.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' -import { Keyboard, Platform } from 'react-native' +import { Keyboard } from 'react-native' +import { hostOs } from './host-os' /** How much of the bottom of the layout viewport it covers, and whether it is open at all. */ export type SoftKeyboardState = { readonly height: number; readonly visible: boolean } @@ -44,8 +45,8 @@ export function subscribeSoftKeyboard( onShow: (height: number, duration: number) => void, onHide: (duration: number) => void ): () => void { - const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' - const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' + const showEvent = hostOs() === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow' + const hideEvent = hostOs() === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide' const show = Keyboard.addListener(showEvent, (event) => onShow(event.endCoordinates.height, event.duration) ) @@ -68,17 +69,3 @@ export function currentSoftKeyboardHeight(): number { export function useKeyboardOcclusion(): number { return useSoftKeyboard().height } - -/** - * The bottom padding a composer needs to clear the keyboard, which natively is none. - * - * `KeyboardAvoidingView` already moves the composer on a phone, so adding padding there would move - * it twice. It is inert on the web for the same reason the `Keyboard` stub is — it is driven by - * those events — so there the padding is the whole of the avoidance. - * - * A second name rather than a `Platform.OS` branch at the call site: this one subscribes to nothing - * on a phone, so a composer that asks for it renders exactly as many times as it does today. - */ -export function useKeyboardAvoidingPadding(): number { - return 0 -} diff --git a/mobile/src/platform/keyboard-occlusion.web.test.tsx b/mobile/src/platform/keyboard-occlusion.web.test.tsx index 11e96afff8f..b6bae0e8547 100644 --- a/mobile/src/platform/keyboard-occlusion.web.test.tsx +++ b/mobile/src/platform/keyboard-occlusion.web.test.tsx @@ -5,9 +5,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { currentSoftKeyboardHeight, subscribeSoftKeyboard, - useKeyboardAvoidingPadding, useKeyboardOcclusion, useSoftKeyboard, + publishShellKeyboardSource, type SoftKeyboardState } from './keyboard-occlusion.web' @@ -62,11 +62,9 @@ class FakeVisualViewport extends EventTarget { const LAYOUT_HEIGHT = 800 let viewport: FakeVisualViewport | null = null let lift = 0 -let padding = 0 function Harness(): null { lift = useKeyboardOcclusion() - padding = useKeyboardAvoidingPadding() return null } @@ -83,7 +81,6 @@ async function mount(): Promise> { beforeEach(() => { lift = 0 - padding = 0 viewport = new FakeVisualViewport(LAYOUT_HEIGHT) Object.defineProperty(window, 'innerHeight', { value: LAYOUT_HEIGHT, configurable: true }) Object.defineProperty(window, 'visualViewport', { value: viewport, configurable: true }) @@ -189,15 +186,9 @@ describe('the keyboard the browser reports', () => { expect(lift).toBe(0) }) - it('is the whole of the avoidance here, where KeyboardAvoidingView is inert', async () => { - await mount() - await act(async () => viewport?.resizeTo(464)) - expect(padding).toBe(336) - }) - it('removes both listeners on unmount', async () => { const tree = await mount() - expect(viewport?.counts).toEqual({ resize: 2, scroll: 2 }) + expect(viewport?.counts).toEqual({ resize: 1, scroll: 1 }) await act(async () => tree.unmount()) expect(viewport?.counts).toEqual({ resize: 0, scroll: 0 }) }) @@ -237,7 +228,7 @@ describe('the keyboard as events, for a sheet', () => { expect(lift).toBe(0) }) - it('stays silent when nothing is covered, which is the shell shortening the WebView', () => { + it('stays silent while the visual viewport covers nothing', () => { const calls: string[] = [] const unsubscribe = subscribeSoftKeyboard( () => calls.push('show'), @@ -262,7 +253,6 @@ describe('the keyboard as events, for a sheet', () => { }) }) -const LAYOUT_WIDTH = 400 let keyboardState: SoftKeyboardState = { height: 0, visible: false } function StateHarness(): null { @@ -270,16 +260,6 @@ function StateHarness(): null { return null } -function resizeWindow(width: number, height: number): void { - Object.defineProperty(window, 'innerWidth', { value: width, configurable: true }) - Object.defineProperty(window, 'innerHeight', { value: height, configurable: true }) - window.dispatchEvent(new Event('resize')) -} - -/** Tracked so a case that resizes the window is not also re-rendering an earlier case's harness, - * which shares this file's one `keyboardState`. */ -const mountedStateHarnesses: ReturnType[] = [] - async function mountState(): Promise> { let tree: ReturnType | null = null await act(async () => { @@ -288,65 +268,102 @@ async function mountState(): Promise> { if (tree === null) { throw new Error('the harness did not mount') } - mountedStateHarnesses.push(tree) return tree } -/** - * The shell shortens the WebView to sit above the IME, so by the time the page measures itself - * nothing is covered and `visualViewport` reads full height. The resize is what is left. - */ -describe('the keyboard the page cannot see, because the shell already moved it', () => { - beforeEach(() => { - act(() => { - for (const tree of mountedStateHarnesses.splice(0)) { - tree.unmount() +/** The shell's side of the bridge, as the entry publishes it: the height `init` last carried. */ +function createShellKeyboard(initial = 0) { + let height = initial + const listeners = new Set<(height: number) => void>() + return { + source: { + read: () => height, + subscribe: (listener: (height: number) => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } } - }) - keyboardState = { height: 0, visible: false } - Object.defineProperty(window, 'innerWidth', { value: LAYOUT_WIDTH, configurable: true }) - Object.defineProperty(window, 'innerHeight', { value: LAYOUT_HEIGHT, configurable: true }) - }) + }, + move: (next: number) => { + height = next + for (const listener of listeners) { + listener(next) + } + }, + listenerCount: () => listeners.size + } +} - it('reads no keyboard while the window keeps the height it mounted at', async () => { - await mountState() - expect(keyboardState).toEqual({ height: 0, visible: false }) - }) - - it('calls the window shortened at an unchanged width the keyboard, and covers nothing by it', async () => { - await mountState() - await act(async () => resizeWindow(LAYOUT_WIDTH, LAYOUT_HEIGHT - 336)) - expect(keyboardState).toEqual({ height: 0, visible: true }) - }) - - it('drops the flag when the window gets its height back', async () => { - await mountState() - await act(async () => resizeWindow(LAYOUT_WIDTH, LAYOUT_HEIGHT - 336)) - await act(async () => resizeWindow(LAYOUT_WIDTH, LAYOUT_HEIGHT)) - expect(keyboardState.visible).toBe(false) - }) - - it('reads a rotation as a new window rather than a keyboard, because the width moved too', async () => { - await mountState() - await act(async () => resizeWindow(LAYOUT_HEIGHT, LAYOUT_WIDTH)) - expect(keyboardState.visible).toBe(false) - // And the shorter window is now the one the next keyboard is measured against. - await act(async () => resizeWindow(LAYOUT_HEIGHT, LAYOUT_WIDTH - 200)) - expect(keyboardState.visible).toBe(true) - }) - - it('takes a window that grew as the new resting height, not as a keyboard closing twice', async () => { - await mountState() - await act(async () => resizeWindow(LAYOUT_WIDTH, LAYOUT_HEIGHT + 60)) - expect(keyboardState.visible).toBe(false) - await act(async () => resizeWindow(LAYOUT_WIDTH, LAYOUT_HEIGHT)) - expect(keyboardState.visible).toBe(true) - }) - - it('stops listening on unmount', async () => { +describe('the state a native screen reads, from the browser outside the shell', () => { + it('is open exactly while something is covered', async () => { const tree = await mountState() + expect(keyboardState).toEqual({ height: 0, visible: false }) + await act(async () => viewport?.resizeTo(464)) + expect(keyboardState).toEqual({ height: 336, visible: true }) + await act(async () => viewport?.resizeTo(LAYOUT_HEIGHT)) + expect(keyboardState).toEqual({ height: 0, visible: false }) await act(async () => tree.unmount()) - await act(async () => resizeWindow(LAYOUT_WIDTH, LAYOUT_HEIGHT - 336)) - expect(keyboardState.visible).toBe(false) + }) +}) + +/** + * Inside the shell the keyboard covers the page as it covers a native screen, but the page cannot + * measure it: the WebView's IME insets are zeroed, so `visualViewport` never moves. The shell says + * the height over the bridge, and every reader here answers from that. + */ +describe('the keyboard the shell says covers the page', () => { + afterEach(() => { + publishShellKeyboardSource(null) + }) + + it('answers the height the shell sent, and ignores the visual viewport', async () => { + const shell = createShellKeyboard() + publishShellKeyboardSource(shell.source) + const tree = await mountState() + const lifted = await mount() + // Pixel_API_37: the IME opens at 312 and the suggestion strip grows it to 346. + for (const height of [312, 346]) { + await act(async () => shell.move(height)) + expect(keyboardState).toEqual({ height, visible: true }) + expect(lift).toBe(height) + } + await act(async () => viewport?.resizeTo(464)) + expect(keyboardState.height).toBe(346) + await act(async () => shell.move(0)) + expect(keyboardState).toEqual({ height: 0, visible: false }) + expect(lift).toBe(0) + await act(async () => { + tree.unmount() + lifted.unmount() + }) + expect(shell.listenerCount()).toBe(0) + }) + + it('reads a keyboard already up at mount, which the shell sent in the first init', async () => { + publishShellKeyboardSource(createShellKeyboard(312).source) + expect(currentSoftKeyboardHeight()).toBe(312) + const tree = await mountState() + expect(keyboardState).toEqual({ height: 312, visible: true }) + await act(async () => tree.unmount()) + }) + + it('hands a sheet each height as a show and the close as one hide', () => { + const shell = createShellKeyboard() + publishShellKeyboardSource(shell.source) + const calls: string[] = [] + const unsubscribe = subscribeSoftKeyboard( + (height, duration) => calls.push(`show ${height} ${duration}`), + (duration) => calls.push(`hide ${duration}`) + ) + viewport?.resizeTo(464) + shell.move(312) + shell.move(346) + shell.move(0) + shell.move(0) + expect(calls).toEqual(['show 312 0', 'show 346 0', 'hide 0']) + unsubscribe() + expect(shell.listenerCount()).toBe(0) + expect(viewport?.counts).toEqual({ resize: 0, scroll: 0 }) }) }) diff --git a/mobile/src/platform/keyboard-occlusion.web.ts b/mobile/src/platform/keyboard-occlusion.web.ts index 05500f369ee..c157f24cb4c 100644 --- a/mobile/src/platform/keyboard-occlusion.web.ts +++ b/mobile/src/platform/keyboard-occlusion.web.ts @@ -1,7 +1,24 @@ import { useEffect, useState } from 'react' +/** The height the shell last said over the bridge, and a way to hear it move. */ +export type ShellKeyboardSource = { + read: () => number + subscribe: (listener: (height: number) => void) => () => void +} + +let shellKeyboard: ShellKeyboardSource | null = null + /** - * Web sibling: the keyboard's height as the browser reports it, which is not as an event. + * Called once by the page entry. Inside the shell the keyboard covers the page as it covers a + * native screen, but the WebView's IME insets are zeroed, so `visualViewport` never moves: the + * shell is the only thing that knows the height, and every reader below answers from it. + */ +export function publishShellKeyboardSource(source: ShellKeyboardSource | null): void { + shellKeyboard = source +} + +/** + * Outside the shell: the keyboard's height as the browser reports it, which is not as an event. * * react-native-web's `Keyboard` is a stub whose `addListener` returns a subscription that never * fires, so every screen waiting for `keyboardDidShow` inside the shell's page waits forever and @@ -48,21 +65,15 @@ function visualViewport(): VisualViewport | undefined { } /** - * The strip as events, with duration 0: the browser has already moved the viewport by the time it - * says so. Inside the shell nothing fires, because the shell shortens the WebView above the IME and - * the strip stays 0; in a plain mobile browser the keyboard overlays the page and this is the lift. + * The strip as events, with duration 0: the shell and the browser have both moved the keyboard by + * the time they say so. */ export function subscribeSoftKeyboard( onShow: (height: number, duration: number) => void, onHide: (duration: number) => void ): () => void { - const viewport = visualViewport() - if (viewport === undefined) { - return () => {} - } - let open = occlusion(viewport) > 0 - const read = (): void => { - const height = occlusion(viewport) + let open = currentSoftKeyboardHeight() > 0 + const read = (height: number): void => { if (height > 0) { open = true onShow(height, 0) @@ -71,17 +82,28 @@ export function subscribeSoftKeyboard( onHide(0) } } - viewport.addEventListener('resize', read) - viewport.addEventListener('scroll', read) + if (shellKeyboard !== null) { + return shellKeyboard.subscribe(read) + } + const viewport = visualViewport() + if (viewport === undefined) { + return () => {} + } + const measure = (): void => read(occlusion(viewport)) + viewport.addEventListener('resize', measure) + viewport.addEventListener('scroll', measure) return () => { - viewport.removeEventListener('resize', read) - viewport.removeEventListener('scroll', read) + viewport.removeEventListener('resize', measure) + viewport.removeEventListener('scroll', measure) } } /** A keyboard already up when a composer opens gets no event at all. */ export function currentSoftKeyboardHeight(): number { + if (shellKeyboard !== null) { + return shellKeyboard.read() + } const viewport = visualViewport() return viewport === undefined ? 0 : occlusion(viewport) } @@ -100,50 +122,10 @@ export function useKeyboardOcclusion(): number { return keyboardLift } -/** - * On the web the padding is the whole of the avoidance: `KeyboardAvoidingView` is driven by the - * `Keyboard` events this file exists because the page never receives. - */ -export function useKeyboardAvoidingPadding(): number { - return useKeyboardOcclusion() -} - -/** The sibling's shape; the two facts it answers together are measured separately here. */ +/** The sibling's shape. Open is a covered strip here: neither source reports a keyboard of 0. */ export type SoftKeyboardState = { readonly height: number; readonly visible: boolean } -/** - * Whether a keyboard is open, which here is not what it covers: the shell shortens the WebView to - * sit above the IME, so nothing covers the page and the occlusion above reads 0, correctly. The - * resize is what is left of the keyboard — inside the shell's WebView the shell's own bottom - * padding is the one thing that changes this window's height without changing its width too. - */ -function useShortenedWindow(): boolean { - const [shortened, setShortened] = useState(false) - - useEffect(() => { - // The tallest height seen at this width is the resting one; a width change is a rotation or a - // fold, which starts the comparison again. A page that mounts with the keyboard already up - // reads false until it closes once, which costs one terminal refit and no correctness. - let width = window.innerWidth - let tallest = window.innerHeight - const read = (): void => { - if (window.innerWidth !== width) { - width = window.innerWidth - tallest = window.innerHeight - } else if (window.innerHeight > tallest) { - tallest = window.innerHeight - } - setShortened(window.innerHeight < tallest) - } - read() - window.addEventListener('resize', read) - - return () => window.removeEventListener('resize', read) - }, []) - - return shortened -} - export function useSoftKeyboard(): SoftKeyboardState { - return { height: useKeyboardOcclusion(), visible: useShortenedWindow() } + const height = useKeyboardOcclusion() + return { height, visible: height > 0 } } diff --git a/mobile/src/platform/keyboard-persisting-taps.ts b/mobile/src/platform/keyboard-persisting-taps.ts new file mode 100644 index 00000000000..77185a5f2b3 --- /dev/null +++ b/mobile/src/platform/keyboard-persisting-taps.ts @@ -0,0 +1,6 @@ +export type KeyboardPersistingTaps = 'always' | 'handled' + +/** Natively a Pressable never takes focus; the ScrollView's `keyboardShouldPersistTaps` is the rule. */ +export function useKeyboardPersistingTaps(_mode: KeyboardPersistingTaps): undefined { + return undefined +} diff --git a/mobile/src/platform/keyboard-persisting-taps.web.test.ts b/mobile/src/platform/keyboard-persisting-taps.web.test.ts new file mode 100644 index 00000000000..9bea438b405 --- /dev/null +++ b/mobile/src/platform/keyboard-persisting-taps.web.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +/** + * The page's form of `keyboardShouldPersistTaps`, which react-native-web ignores. + * + * Measured on Pixel_API_37: a tap on a dock key or a session tab moves focus from the live input + * to the Pressable (tabindex 0) on mousedown, and the IME closes. Natively a Pressable never takes + * focus, so the same taps keep the keyboard up. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { keepTextFocusThroughTaps } from './keyboard-persisting-taps.web' + +function plant() { + const container = document.createElement('div') + const button = document.createElement('div') + button.tabIndex = 0 + const label = document.createElement('span') + button.appendChild(label) + const gap = document.createElement('span') + const field = document.createElement('input') + container.append(button, gap, field) + const liveInput = document.createElement('input') + document.body.append(container, liveInput) + return { container, label, gap, field, liveInput } +} + +/** Whether a mousedown on `target` was kept from moving focus. */ +function pressKeepsFocus(target: Element): boolean { + const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + target.dispatchEvent(event) + return event.defaultPrevented +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('keepTextFocusThroughTaps', () => { + it("keeps a text field's focus through a tap on a pressable, in both modes", () => { + for (const mode of ['always', 'handled'] as const) { + const { container, label, liveInput } = plant() + const release = keepTextFocusThroughTaps(container, mode) + liveInput.focus() + expect(pressKeepsFocus(label), mode).toBe(true) + release() + document.body.innerHTML = '' + } + }) + + it('lets a tap on nothing pressable dismiss only when taps are handled', () => { + const always = plant() + keepTextFocusThroughTaps(always.container, 'always') + always.liveInput.focus() + expect(pressKeepsFocus(always.gap)).toBe(true) + document.body.innerHTML = '' + + const handled = plant() + keepTextFocusThroughTaps(handled.container, 'handled') + handled.liveInput.focus() + expect(pressKeepsFocus(handled.gap)).toBe(false) + }) + + it('leaves focus alone when no text field holds it, or when the tap is on a text field', () => { + const { container, label, field, liveInput } = plant() + keepTextFocusThroughTaps(container, 'always') + expect(pressKeepsFocus(label)).toBe(false) + liveInput.focus() + expect(pressKeepsFocus(field)).toBe(false) + }) + + it('does not count a focusable ancestor above the container as a pressable in the bar', () => { + const outer = document.createElement('div') + outer.tabIndex = 0 + const { container, gap, liveInput } = plant() + outer.append(container) + document.body.prepend(outer) + keepTextFocusThroughTaps(container, 'handled') + liveInput.focus() + expect(pressKeepsFocus(gap)).toBe(false) + }) + + it('stops when released', () => { + const { container, label, liveInput } = plant() + keepTextFocusThroughTaps(container, 'always')() + liveInput.focus() + expect(pressKeepsFocus(label)).toBe(false) + }) +}) diff --git a/mobile/src/platform/keyboard-persisting-taps.web.ts b/mobile/src/platform/keyboard-persisting-taps.web.ts new file mode 100644 index 00000000000..ae05cf0355f --- /dev/null +++ b/mobile/src/platform/keyboard-persisting-taps.web.ts @@ -0,0 +1,56 @@ +import { useCallback, useRef } from 'react' +import type { KeyboardPersistingTaps } from './keyboard-persisting-taps' + +export type { KeyboardPersistingTaps } from './keyboard-persisting-taps' + +function isTextField(element: Element | null): boolean { + if (!(element instanceof HTMLElement)) { + return false + } + return ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element.isContentEditable + ) +} + +/** A focusable element between the tap and the container; an ancestor above it is not in the bar. */ +function isPressableInside(container: HTMLElement, target: Element): boolean { + const pressable = target.closest('[tabindex]:not([tabindex="-1"])') + return pressable !== null && pressable !== container && container.contains(pressable) +} + +/** + * `keyboardShouldPersistTaps` for the page: react-native-web ignores it, and a Pressable there is a + * focusable element that takes focus on mousedown, which blurs the text field and closes the IME. + * `handled` keeps focus only for a tap on something pressable; `always` for any tap. + */ +export function keepTextFocusThroughTaps( + container: HTMLElement, + mode: KeyboardPersistingTaps +): () => void { + const onMouseDown = (event: MouseEvent) => { + const target = event.target instanceof Element ? event.target : null + if (!isTextField(document.activeElement) || !target || isTextField(target)) { + return + } + if (mode === 'handled' && !isPressableInside(container, target)) { + return + } + event.preventDefault() + } + container.addEventListener('mousedown', onMouseDown, true) + return () => container.removeEventListener('mousedown', onMouseDown, true) +} + +/** A ref for the View that holds the taps; on the page that View is its DOM element. */ +export function useKeyboardPersistingTaps(mode: KeyboardPersistingTaps) { + const releaseRef = useRef<(() => void) | null>(null) + return useCallback( + (node: unknown) => { + releaseRef.current?.() + releaseRef.current = node instanceof HTMLElement ? keepTextFocusThroughTaps(node, mode) : null + }, + [mode] + ) +} diff --git a/mobile/src/platform/software-keyboard-window-inset.test.ts b/mobile/src/platform/software-keyboard-window-inset.test.ts deleted file mode 100644 index b458993d0ea..00000000000 --- a/mobile/src/platform/software-keyboard-window-inset.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { softwareKeyboardWindowInset } from './software-keyboard-window-inset' - -describe('the strip of the window the software keyboard takes', () => { - it('is nothing while the keyboard is closed', () => { - expect( - softwareKeyboardWindowInset({ keyboardHeight: 0, bottomInset: 48, platform: 'android' }) - ).toBe(0) - }) - - it('is the iOS frame height as reported, which already spans the home indicator', () => { - expect( - softwareKeyboardWindowInset({ keyboardHeight: 336, bottomInset: 34, platform: 'ios' }) - ).toBe(336) - }) - - it('adds the navigation bar back on Android, whose IME height stops above it', () => { - // The session dock is the proof this split is real: it pads itself by the inset and then - // translates by the raw height, which lands on the keys only if Android's excludes the bar. - expect( - softwareKeyboardWindowInset({ keyboardHeight: 336, bottomInset: 48, platform: 'android' }) - ).toBe(384) - }) - - it('takes the page host as a phone, since it is one: the shell runs no web build', () => { - expect( - softwareKeyboardWindowInset({ keyboardHeight: 300, bottomInset: 20, platform: 'web' }) - ).toBe(320) - }) - - it('never reports a negative strip, whatever the event and the inset say', () => { - expect( - softwareKeyboardWindowInset({ keyboardHeight: -10, bottomInset: 48, platform: 'android' }) - ).toBe(0) - expect( - softwareKeyboardWindowInset({ keyboardHeight: 336, bottomInset: -48, platform: 'android' }) - ).toBe(336) - }) -}) diff --git a/mobile/src/platform/software-keyboard-window-inset.ts b/mobile/src/platform/software-keyboard-window-inset.ts deleted file mode 100644 index 0c15bba7c2a..00000000000 --- a/mobile/src/platform/software-keyboard-window-inset.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * The strip at the bottom of the window the software keyboard takes, for a view that owns the - * window and has to shorten itself out of the way. The two platforms measure the IME from - * different edges: iOS's frame height starts at the bottom of the window and already spans the - * home indicator, Android's starts above the system bars, so only Android adds the bar back. - */ -export function softwareKeyboardWindowInset(input: { - keyboardHeight: number - bottomInset: number - platform: 'ios' | 'android' | 'windows' | 'macos' | 'web' -}): number { - const keyboardHeight = Math.max(0, input.keyboardHeight) - if (keyboardHeight === 0) { - return 0 - } - return input.platform === 'ios' ? keyboardHeight : keyboardHeight + Math.max(0, input.bottomInset) -} diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 9b480fdd7d9..a290a999161 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -148,7 +148,6 @@ function MobileNativeChatMessageImpl({ {turnStatus ? ( onToggleTurn(turnKey) : undefined} diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index 6b6a1d49e07..e99412b42bf 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -19,27 +19,34 @@ vi.mock('react-native', async () => { }) vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) -import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' +import { + MobileNativeChatTurnActivity, + MobileNativeChatTurnStatus +} from './MobileNativeChatTurnStatus' + +const labels = (node: ReactTestInstance): string[] => + node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) + +const spinners = (node: ReactTestInstance): ReactTestInstance[] => + node.findAllByType('ActivityIndicator' as never) + +let renderer: ReactTestRenderer | null = null + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-04T00:00:00Z')) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() +}) describe('MobileNativeChatTurnStatus', () => { - let renderer: ReactTestRenderer | null = null - - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-09-04T00:00:00Z')) - }) - - afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - vi.useRealTimers() - }) - function render(props: { startedAt: number | null - thinking: boolean workedSeconds?: number | null - activityText?: string | null expanded?: boolean onToggleExpanded?: () => void }): ReactTestRenderer { @@ -49,49 +56,19 @@ describe('MobileNativeChatTurnStatus', () => { return renderer! } - const labels = (node: ReactTestInstance): string[] => - node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) - - const spinners = (node: ReactTestInstance): ReactTestInstance[] => - node.findAllByType('ActivityIndicator' as never) - - it('reads "Thinking" beside one spinner while the turn reasons', () => { - const tree = render({ startedAt: Date.now(), thinking: true }) - expect(labels(tree.root)).toEqual(['Thinking']) - expect(spinners(tree.root)).toHaveLength(1) - }) - - it('counts up on that same single row when the turn is not reasoning', () => { - const startedAt = Date.now() - const tree = render({ startedAt, thinking: false }) + it('counts up from the first second while the turn runs, with no spinner', () => { + const tree = render({ startedAt: Date.now() }) expect(labels(tree.root)).toEqual(['Working for 0s']) act(() => { vi.advanceTimersByTime(12_000) }) expect(labels(tree.root)).toEqual(['Working for 12s']) - expect(spinners(tree.root)).toHaveLength(1) - }) - - it('lets provider activity text beat both fallbacks and hold the clock', () => { - const tree = render({ - startedAt: Date.now(), - thinking: true, - activityText: 'Running pnpm test' - }) - expect(labels(tree.root)).toEqual(['Running pnpm test']) - expect(spinners(tree.root)).toHaveLength(1) - // No label consumes the duration, so nothing schedules a tick for it. - expect(vi.getTimerCount()).toBe(0) + expect(spinners(tree.root)).toHaveLength(0) }) it('settles to a tappable "Worked for" row that toggles the turn', () => { const onToggleExpanded = vi.fn() - const tree = render({ - startedAt: Date.now(), - thinking: false, - workedSeconds: 184, - onToggleExpanded - }) + const tree = render({ startedAt: Date.now(), workedSeconds: 184, onToggleExpanded }) expect(labels(tree.root)).toEqual(['Worked for 3m 4s']) const button = tree.root.findByType('Pressable' as never) expect(button.props.accessibilityLabel).toBe('Toggle turn details') @@ -101,19 +78,46 @@ describe('MobileNativeChatTurnStatus', () => { }) it('stays a plain row when the settled turn has nothing to disclose', () => { - const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 }) + const tree = render({ startedAt: Date.now(), workedSeconds: 5 }) expect(tree.root.findAllByType('Pressable' as never)).toHaveLength(0) expect(labels(tree.root)).toEqual(['Worked for 5s']) }) - it('holds no interval, and no spinner, once the turn has settled', () => { - const tree = render({ startedAt: Date.now(), thinking: false, workedSeconds: 5 }) + it('holds no interval once the turn has settled', () => { + render({ startedAt: Date.now(), workedSeconds: 5 }) expect(vi.getTimerCount()).toBe(0) - expect(spinners(tree.root)).toHaveLength(0) + }) +}) + +describe('MobileNativeChatTurnActivity', () => { + function render(props: { thinking: boolean; activityText?: string | null }): ReactTestRenderer { + act(() => { + renderer = create(createElement(MobileNativeChatTurnActivity, props)) + }) + return renderer! + } + + it('reads "Thinking" beside one spinner while the turn reasons', () => { + const tree = render({ thinking: true }) + expect(labels(tree.root)).toEqual(['Thinking']) + expect(spinners(tree.root)).toHaveLength(1) }) - it('announces the live row to assistive tech', () => { - const tree = render({ startedAt: Date.now(), thinking: true }) + // The bar owns the clock; the tail line never repeats it. + it('reads plain "Working…" when the turn is not reasoning, and holds no timer', () => { + const tree = render({ thinking: false }) + expect(labels(tree.root)).toEqual(['Working…']) + expect(vi.getTimerCount()).toBe(0) + }) + + it('lets provider activity text beat both fallbacks', () => { + const tree = render({ thinking: true, activityText: 'Running pnpm test' }) + expect(labels(tree.root)).toEqual(['Running pnpm test']) + expect(spinners(tree.root)).toHaveLength(1) + }) + + it('announces the live line to assistive tech', () => { + const tree = render({ thinking: true }) const row = tree.root.findByType('View' as never) expect(row.props.accessibilityLiveRegion).toBe('polite') expect(row.props.accessibilityLabel).toBe('Agent is responding') diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx index acf922265f6..25003685240 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -26,38 +26,28 @@ function useElapsedSeconds(startedAt: number | null, counting: boolean): number return counting ? nativeChatElapsedSeconds(startedAt, mountedAt, now) : 0 } -/** The per-turn status row. While the turn runs it is the one live indicator — a - * spinner beside what the provider says it is doing, else "Thinking", else - * "Working for 12s". It settles to a tappable "Worked for 3m 4s" that discloses - * the turn's tool activity. Desktop parity: `NativeChatTurnActivityLine` for the - * live row, `NativeChatWorkingStatus` for the settled one. */ +/** The turn bar under the user's message: "Working for 12s" while the turn runs, + * settling in place to a tappable "Worked for 3m 4s" that discloses the turn's + * tool activity. Desktop parity: `NativeChatWorkingStatus`. */ export function MobileNativeChatTurnStatus({ startedAt, - thinking, workedSeconds, - activityText, expanded = false, onToggleExpanded }: { startedAt: number | null - thinking: boolean workedSeconds?: number | null - /** Provider activity copy for a live turn; outranks the other two labels. */ - activityText?: string | null expanded?: boolean onToggleExpanded?: () => void }): React.JSX.Element { const settled = workedSeconds != null - const counting = !settled && !thinking && !activityText?.trim() - const elapsedSeconds = useElapsedSeconds(startedAt, counting) - const label = settled - ? formatNativeChatTurnStatusLabel({ thinking, workedSeconds, elapsedSeconds }) - : formatNativeChatActiveTurnLabel({ activityText, thinking, elapsedSeconds }) + const elapsedSeconds = useElapsedSeconds(startedAt, !settled) + const label = formatNativeChatTurnStatusLabel({ workedSeconds, elapsedSeconds }) if (settled && onToggleExpanded) { return ( [styles.row, styles.rowSettled, pressed && styles.pressed]} + style={({ pressed }) => [styles.row, styles.bar, pressed && styles.pressed]} onPress={onToggleExpanded} hitSlop={6} accessibilityRole="button" @@ -72,15 +62,34 @@ export function MobileNativeChatTurnStatus({ ) } + return ( + + + {label} + + + ) +} + +/** The live turn's tail line: a spinner beside what the provider says it is doing, + * else "Thinking", else "Working…". The clock stays in the turn bar. Desktop + * parity: `NativeChatTurnActivityLine`. */ +export function MobileNativeChatTurnActivity({ + thinking, + activityText +}: { + thinking: boolean + activityText?: string | null +}): React.JSX.Element { return ( - {settled ? null : } + - {label} + {formatNativeChatActiveTurnLabel({ activityText, thinking })} ) @@ -94,7 +103,7 @@ const styles = StyleSheet.create({ minHeight: 28, paddingHorizontal: spacing.md }, - rowSettled: { + bar: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.borderSubtle }, diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 6b80c3500ae..fa29e13c8c8 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -703,14 +703,14 @@ describe('MobileNativeChatView', () => { return renderer!.root.findAll((node) => node.type === 'WorkingIndicator') } - it('puts the live status at the turn tail and drops the three-dot indicator', async () => { + 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).toBeNull() - // Nothing reports reasoning, so the one live footer counts instead of guessing. - expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null }) + 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) @@ -778,7 +778,7 @@ describe('MobileNativeChatView', () => { expect(renderer!.root.findAll((node) => node.type === testCase.cardType)).toHaveLength(1) await update(working) - expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null }) + expect(footerProps()).toMatchObject({ thinking: false }) expect(rowProps('a1').activeTurnIsWorking).toBe(true) }) @@ -791,8 +791,8 @@ describe('MobileNativeChatView', () => { agentWorking: true, turnIndicator: { thinking: true, activityText: null } }) - expect(rowProps('u1').turnStatus).toBeNull() - expect(footerProps()).toMatchObject({ thinking: true, workedSeconds: 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 () => { @@ -838,8 +838,8 @@ describe('MobileNativeChatView', () => { 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).toBeNull() - expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null }) + 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 }) @@ -857,7 +857,7 @@ describe('MobileNativeChatView', () => { expect(rowProps('a1').turnStatus).toBeNull() // The assistant row still belongs to the live turn, so its tool row stays visible. expect(rowProps('a1').activeTurnIsWorking).toBe(true) - expect(footerProps()).toMatchObject({ workedSeconds: null }) + expect(footerProps()).toMatchObject({ thinking: false }) }) it('does not carry a running turn clock across chat surfaces', async () => { @@ -872,7 +872,7 @@ describe('MobileNativeChatView', () => { agentWorking: true, sendSurfaceId: 'host\0worktree\0tab-a' }) - expect(footerProps()).toMatchObject({ startedAt: 1_000 }) + expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 }) vi.setSystemTime(12_000) const secondTab = [userTurn('u2', 'second')] @@ -884,7 +884,7 @@ describe('MobileNativeChatView', () => { sendSurfaceId: 'host\0worktree\0tab-b' }) - expect(footerProps()).toMatchObject({ startedAt: 12_000 }) + expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 }) } finally { vi.useRealTimers() } diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 6a51601d151..050a532de3c 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -28,7 +28,7 @@ import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch- import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' -import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' +import { MobileNativeChatTurnActivity } from './MobileNativeChatTurnStatus' import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' @@ -338,10 +338,8 @@ export function MobileNativeChatView({ agentWorking && !hasPendingStructuredInteraction && turns.active ? ( - ) : null diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index eb7b6c558dd..9c4f0eb313f 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -32,8 +32,6 @@ export function MobileSessionActiveContent({ setShowCreateTabDrawer, dictationMode, toastMessage, - terminalFrameHeightRef, - setTerminalFrameWidth, handleTerminalTap, browserScreencastSupported, showToast, @@ -53,9 +51,10 @@ export function MobileSessionActiveContent({ copyMarkdownLocalContent, discardMarkdownLocalContent, saveMarkdownTab, - notifyTerminalFrameHeight, setTerminalWebViewRef, handleTerminalWebReady, + notifyTerminalFrame, + notifyTerminalCellBoxChange, handleFileTap, handleNativeChatFileTap, handleTerminalOpenUrl, @@ -208,6 +207,7 @@ export function MobileSessionActiveContent({ }} onRef={setTerminalWebViewRef} onWebReady={handleTerminalWebReady} + onCellBoxChange={notifyTerminalCellBoxChange} onSelectionMode={handleSelectionMode} onSelectionCopy={handleSelectionCopy} onSelectionEvicted={handleSelectionEvicted} @@ -249,12 +249,8 @@ export function MobileSessionActiveContent({ // Why: one frame under every branch; react-native-web observes onLayout only on a View that mounts with it. style={styles.contentFrame} onLayout={(e) => { - terminalFrameHeightRef.current = e.nativeEvent.layout.height - // Why: notify height imperatively so dock settling re-fits the PTY without rerendering SessionScreen. - const nextWidth = Math.round(e.nativeEvent.layout.width) - const nextHeight = Math.round(e.nativeEvent.layout.height) - setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth)) - notifyTerminalFrameHeight(nextHeight) + const { width, height } = e.nativeEvent.layout + notifyTerminalFrame({ width, height }) }} > {content} diff --git a/mobile/src/session/MobileSessionCommandDock.tsx b/mobile/src/session/MobileSessionCommandDock.tsx index ed12bb6e51a..87b46246572 100644 --- a/mobile/src/session/MobileSessionCommandDock.tsx +++ b/mobile/src/session/MobileSessionCommandDock.tsx @@ -1,4 +1,5 @@ -import { View, Text, ScrollView, TextInput, Pressable, Platform } from 'react-native' +import { View, Text, ScrollView, TextInput, Pressable } from 'react-native' +import { hostOs } from '../platform/host-os' import { ArrowUp, ChevronDown, @@ -19,6 +20,7 @@ import { MobileTerminalInputActions } from './MobileTerminalInputActions' import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-session-styles' +import { useKeyboardPersistingTaps } from '../platform/keyboard-persisting-taps' import type { MobileSessionController } from './use-mobile-session-controller' export function MobileSessionCommandDock({ controller }: { controller: MobileSessionController }) { @@ -65,6 +67,7 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes activeBrowserTab, keyboardLift } = controller + const accessoryBarKeepsKeyboard = useKeyboardPersistingTaps('always') return ( !activeMarkdownTab && !activeFileTab && @@ -77,7 +80,7 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes ]} > {/* Accessory keys */} - + {/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */} {keyboardLift > 0 && ( @@ -119,7 +121,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC {visibleTabs.length > 0 && ( - + {/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */} void onOpenUrl: (handle: string, url: string) => void onTextScaleChange: (scale: number) => void + onCellBoxChange: (handle: string) => void } export function TerminalPaneView({ @@ -49,7 +50,8 @@ export function TerminalPaneView({ onTerminalTap, onFileTap, onOpenUrl, - onTextScaleChange + onTextScaleChange, + onCellBoxChange }: TerminalPaneViewProps) { const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { @@ -74,6 +76,7 @@ export function TerminalPaneView({ style={styles.terminalWebView} terminalTheme={terminalTheme} textScale={textScale} + shownAtMount={active} onWebReady={() => onWebReady(handle)} onSelectionMode={(a) => onSelectionMode(handle, a)} onSelectionCopy={(t) => onSelectionCopy(handle, t)} @@ -87,6 +90,7 @@ export function TerminalPaneView({ onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)} onOpenUrl={(url) => onOpenUrl(handle, url)} onTextScaleChange={onTextScaleChange} + onCellBoxChange={() => onCellBoxChange(handle)} /> ) diff --git a/mobile/src/session/mobile-session-connection-retry.test.tsx b/mobile/src/session/mobile-session-connection-retry.test.tsx index 0102819b311..410c6dea196 100644 --- a/mobile/src/session/mobile-session-connection-retry.test.tsx +++ b/mobile/src/session/mobile-session-connection-retry.test.tsx @@ -30,7 +30,7 @@ function present(forceReconnectHost: ForceReconnect): Presentation { toastOpacityRef: { current: 1 }, hostEndpoint: null, initialSessionAutoCreateRef: { current: null }, - terminalFrameHeightRef: { current: 0 }, + terminalFrameRef: { current: null }, handleCreateTerminal: () => Promise.resolve(), visibleTabs: [], forceReconnectHost diff --git a/mobile/src/session/mobile-session-frame-styles.ts b/mobile/src/session/mobile-session-frame-styles.ts index c6117fc8aae..22ac97d23c8 100644 --- a/mobile/src/session/mobile-session-frame-styles.ts +++ b/mobile/src/session/mobile-session-frame-styles.ts @@ -70,6 +70,7 @@ export const mobileSessionFrameStyles = StyleSheet.create({ sessionMetaRow: { flexDirection: 'row', alignItems: 'center', + gap: spacing.sm, marginTop: 2 }, sessionMetaText: { diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 4910151d071..6083ecbcd68 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -92,11 +92,23 @@ const HOST_COMPONENT_NAMES = new Set([ // 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. -const HEAD_MAIN_HOOK_SHA256 = 'f161e14a9c53d80c3dc75f51dd8ecb339b59b7239c9c3e067791b8612f51ede2' -// Moved when the prompt-cancel flag became one structured-session host support object. -const HEAD_HOOK_BINDING_SHA256 = 'db9f32cc60fc68adbcbb2acf9f9384ad0d78bbc6feef5d581449fadb647405fc' +// 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 = - '373dca17a060e63d8cb4e32416ca2889b8404cee78f7b47e632940a9980baf23' + '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 @@ -116,8 +128,16 @@ const HEAD_CALLBACK_IDENTITY_SHA256 = // 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. -const HEAD_CALLBACK_BODY_SHA256 = '65f5c1274967cecef39660430169dcfde81b362e8aa5878833b9f20bc8e43a6a' +// 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`. +const HEAD_CALLBACK_BODY_SHA256 = '4848e925f478f1656f26031c1bdebbb9f2da60811c7e91cfd732fc9da9764079' // 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 @@ -179,11 +199,20 @@ const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f342345 // 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. const HEAD_RUNTIME_STRING_SHA256 = - '7dd03af1ad61e2f394b8cba35a422f7195de60ae5d608923b52575a56a4dbe42' -// Moved by both of the dock's fields: their refs, and the live one's submit handler, are the seam's now. -const HEAD_HOST_JSX_SHA256 = '1478283a1597c88920aedfea9f6ed13d119ea8f93546e1628d0918cd0a5248ef' -const HEAD_LEAF_JSX_SHA256 = 'c7e1a4b90197697f1eaa640c38da63281b4f7b84fb036ae2152f00c2f7d7cb77' + 'ab8cc43940d3a3e0fdb8df3bdc178b9b61e51c7ccd55a944933862a3c290e8e3' +// 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 = @@ -580,10 +609,10 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(282) + 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(79) + 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) @@ -625,7 +654,7 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(530) + expect(strings).toHaveLength(531) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) expect(jsx.host).toHaveLength(125) diff --git a/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx b/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx index 80370a44aad..1cec63717c7 100644 --- a/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx +++ b/mobile/src/session/mobile-session-terminal-frame-layout.test.tsx @@ -49,7 +49,7 @@ type Branch = 'loading' | 'pending' | 'terminal' function controller( branch: Branch | boolean, - notifyTerminalFrameHeight: (height: number) => void + notifyTerminalFrame: (frame: { width: number; height: number }) => void ): Controller { const shown = branch === true ? 'loading' : branch === false ? 'terminal' : branch const scope = { @@ -58,9 +58,7 @@ function controller( isPendingTerminalRecoveryParked: false, showEmptyState: false, terminals: [], - terminalFrameHeightRef: { current: 0 }, - setTerminalFrameWidth: () => {}, - notifyTerminalFrameHeight, + notifyTerminalFrame, dictation: { isRecording: false }, nativeChatSendError: { message: null, clear: () => {} } } @@ -71,8 +69,8 @@ function controller( describe('the terminal frame on the page', () => { it('reports its height when the session opens from the loading state', () => { const heights: number[] = [] - const notify = (height: number): void => { - heights.push(height) + const notify = (frame: { height: number }): void => { + heights.push(frame.height) } let renderer: ReturnType | undefined act(() => { @@ -91,8 +89,8 @@ describe('the terminal frame on the page', () => { it('stays one mounted frame across loading, a pending terminal and the terminal', () => { // A frame that remounts per branch reports again on every return; one that stays reports once. const heights: number[] = [] - const notify = (height: number): void => { - heights.push(height) + const notify = (frame: { height: number }): void => { + heights.push(frame.height) } let renderer: ReturnType | undefined const show = (branch: Branch) => { diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 712b181cbbc..bca14e293f7 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -10,13 +10,15 @@ import type { import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation' import { agentSessionRefusalNotice, - agentSessionRpcErrorFailure, agentSessionWriteFailureNotice, - agentSessionWriteKindForMethod, agentSessionWriteNoticeEnglish, - agentSessionWriteNoticeParts, - type AgentSessionWriteKind + agentSessionWriteNoticeParts } from '../../../src/shared/agent-session-refusal-notice' +import { + agentSessionRpcErrorFailure, + agentSessionWriteKindForMethod, + type AgentSessionWriteKind +} from '../../../src/shared/agent-session-write-failure' import { structuredSessionOperationId } from './structured-session-operation-id' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import type { RpcClient } from '../transport/rpc-client' diff --git a/mobile/src/session/mobile-structured-operation-id-retirement.test.ts b/mobile/src/session/mobile-structured-operation-id-retirement.test.ts index 887aeb7e208..82ccd1e19dc 100644 --- a/mobile/src/session/mobile-structured-operation-id-retirement.test.ts +++ b/mobile/src/session/mobile-structured-operation-id-retirement.test.ts @@ -161,6 +161,36 @@ describe('what a structured refusal says on the phone', () => { }) }) + it('says what the host named as the reason, in the same words as desktop', async () => { + const result = await requestStructuredAgentSessionMutation({ + client: fakeClient(async () => ({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'This conversation has been cleared. Use the current conversation.', + details: { reason: 'conversationCleared' } + } + }, + _meta: { runtimeId: 'runtime-1' } + })), + method: 'agentSession.send', + fingerprintMethod: 'agentSession.send', + sessionId: 'session-1', + expectedRuntimeFence: 1, + fields: { body: 'hello' }, + clientOperationId: `1900000000000-${'e'.repeat(32)}` + }) + + expect(result).toEqual({ + status: 'refused', + code: 'agent_session_operation_invalid', + message: + 'This conversation has been cleared. Your message was not sent. Open the current conversation to continue.' + }) + }) + const stop = { method: 'agentSession.cancel', fingerprintMethod: 'agentSession.cancel', diff --git a/mobile/src/session/mobile-terminal-first-subscribe-viewport.test.ts b/mobile/src/session/mobile-terminal-first-subscribe-viewport.test.ts deleted file mode 100644 index e1da3e5d2f1..00000000000 --- a/mobile/src/session/mobile-terminal-first-subscribe-viewport.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - deferFirstSubscribeUntilViewportMeasured, - measureTerminalViewportOnce, - type FirstSubscribeViewportGateArgs, - type TerminalViewportOnceArgs -} from './mobile-terminal-first-subscribe-viewport' - -const PHONE = { cols: 55, rows: 47 } - -function onceArgs(overrides: Partial = {}): TerminalViewportOnceArgs { - return { - handle: 'term-1', - ref: { - init: vi.fn(), - awaitReady: vi.fn(async () => {}), - measureFitDimensions: vi.fn(async () => PHONE) - }, - documentHasTerminal: true, - viewportRef: { current: null }, - viewportMeasuredRef: { current: false }, - terminalFrameHeightRef: { current: 751 }, - onMeasured: vi.fn(), - ...overrides - } -} - -describe('measureTerminalViewportOnce', () => { - it('stores the measured dims against the frame height', async () => { - const args = onceArgs() - await measureTerminalViewportOnce(args) - expect(args.ref?.measureFitDimensions).toHaveBeenCalledWith(751) - expect(args.viewportRef.current).toEqual(PHONE) - expect(args.viewportMeasuredRef.current).toBe(true) - expect(args.onMeasured).toHaveBeenCalledWith('term-1', PHONE, 751) - }) - - it('does nothing once the route has a viewport', async () => { - const args = onceArgs({ viewportMeasuredRef: { current: true } }) - await measureTerminalViewportOnce(args) - expect(args.ref?.measureFitDimensions).not.toHaveBeenCalled() - }) - - it('leaves the viewport unmeasured when the document cannot measure', async () => { - const args = onceArgs({ - ref: { - init: vi.fn(), - awaitReady: vi.fn(async () => {}), - measureFitDimensions: vi.fn(async () => null) - } - }) - await measureTerminalViewportOnce(args) - expect(args.viewportMeasuredRef.current).toBe(false) - expect(args.viewportRef.current).toBe(null) - }) -}) - -describe('measureTerminalViewportOnce on a fresh document', () => { - it('opens an empty terminal and waits for it before measuring, so cell metrics exist', async () => { - const order: string[] = [] - const args = onceArgs({ - documentHasTerminal: false, - ref: { - init: vi.fn((cols: number, rows: number, data?: string) => { - order.push(`init ${cols}x${rows} ${JSON.stringify(data)}`) - }), - awaitReady: vi.fn(async () => { - order.push('awaitReady') - }), - measureFitDimensions: vi.fn(async () => { - order.push('measure') - return PHONE - }) - } - }) - await measureTerminalViewportOnce(args) - expect(order).toEqual(['init 80x24 ""', 'awaitReady', 'measure']) - expect(args.viewportRef.current).toEqual(PHONE) - }) - - it('never re-inits a document that already holds a terminal', async () => { - const args = onceArgs({ documentHasTerminal: true }) - await measureTerminalViewportOnce(args) - expect(args.ref?.init).not.toHaveBeenCalled() - expect(args.viewportRef.current).toEqual(PHONE) - }) -}) - -function gateArgs( - overrides: Partial = {} -): FirstSubscribeViewportGateArgs { - return { - handle: 'term-1', - covered: false, - viewportMeasured: false, - subscribedDocuments: new Set(), - subscribingHandles: new Set(), - subscribeSeq: new Map([['term-1', 1]]), - measure: vi.fn(async () => {}), - subscribe: vi.fn(), - ...overrides - } -} - -describe('deferFirstSubscribeUntilViewportMeasured', () => { - it('holds the first subscribe of a document until the measure lands, then subscribes once', async () => { - let finishMeasure = (): void => {} - const args = gateArgs({ - measure: vi.fn( - () => - new Promise((resolve) => { - finishMeasure = resolve - }) - ) - }) - expect(deferFirstSubscribeUntilViewportMeasured(args)).toBe(true) - expect(args.measure).toHaveBeenCalledWith('term-1') - // Other callers see a subscribe in flight and stay out. - expect(args.subscribingHandles.has('term-1')).toBe(true) - expect(args.subscribe).not.toHaveBeenCalled() - finishMeasure() - await vi.waitFor(() => expect(args.subscribe).toHaveBeenCalledTimes(1)) - expect(args.subscribingHandles.has('term-1')).toBe(false) - // The resumed subscribe passes straight through. - expect(deferFirstSubscribeUntilViewportMeasured(args)).toBe(false) - }) - - it('still subscribes, without dims, when the measure fails', async () => { - const args = gateArgs({ measure: vi.fn(() => Promise.reject(new Error('gone'))) }) - expect(deferFirstSubscribeUntilViewportMeasured(args)).toBe(true) - await vi.waitFor(() => expect(args.subscribe).toHaveBeenCalledTimes(1)) - }) - - function heldSubscribe(subscribeSeq = new Map([['term-1', 1]])) { - let finishMeasure = (): void => {} - const args = gateArgs({ - subscribeSeq, - measure: vi.fn( - () => - new Promise((resolve) => { - finishMeasure = resolve - }) - ) - }) - deferFirstSubscribeUntilViewportMeasured(args) - return { args, finish: () => finishMeasure() } - } - const flush = async () => { - await Promise.resolve() - await Promise.resolve() - } - - it('drops the held subscribe when a newer subscribe generation started during the measure', async () => { - const subscribeSeq = new Map([['term-1', 1]]) - const { args, finish } = heldSubscribe(subscribeSeq) - subscribeSeq.set('term-1', 2) - finish() - await flush() - expect(args.subscribe).not.toHaveBeenCalled() - }) - - it('drops the held subscribe when its in-flight mark was cleared during the measure', async () => { - const { args, finish } = heldSubscribe() - args.subscribingHandles.delete('term-1') - finish() - await flush() - expect(args.subscribe).not.toHaveBeenCalled() - }) - - it('passes through when the viewport is known, but remembers the document', () => { - const args = gateArgs({ viewportMeasured: true }) - expect(deferFirstSubscribeUntilViewportMeasured(args)).toBe(false) - expect(args.subscribedDocuments.has('term-1')).toBe(true) - // A later refit invalidation must not re-open a live terminal's document. - expect(deferFirstSubscribeUntilViewportMeasured({ ...args, viewportMeasured: false })).toBe( - false - ) - expect(args.measure).not.toHaveBeenCalled() - }) - - it('passes through a native-chat-covered lease, which has no document', () => { - const args = gateArgs({ covered: true }) - expect(deferFirstSubscribeUntilViewportMeasured(args)).toBe(false) - expect(args.subscribedDocuments.has('term-1')).toBe(false) - expect(args.measure).not.toHaveBeenCalled() - }) -}) diff --git a/mobile/src/session/mobile-terminal-first-subscribe-viewport.ts b/mobile/src/session/mobile-terminal-first-subscribe-viewport.ts deleted file mode 100644 index e6c79f5c5f9..00000000000 --- a/mobile/src/session/mobile-terminal-first-subscribe-viewport.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract' -import type { MutableRef, TerminalViewportDims } from './mobile-terminal-viewport-resubscribe' - -/** Any size opens xterm; cell metrics, not the grid, are what the measure needs. */ -const EMPTY_TERMINAL_COLS = 80 -const EMPTY_TERMINAL_ROWS = 24 - -export type TerminalViewportMeasureTarget = Pick< - TerminalWebViewHandle, - 'init' | 'awaitReady' | 'measureFitDimensions' -> - -export type TerminalViewportOnceArgs = { - handle: string - ref: TerminalViewportMeasureTarget | undefined - /** False until the document's first init; it has no xterm, so no cell metrics, before one. */ - documentHasTerminal: boolean - viewportRef: MutableRef - viewportMeasuredRef: MutableRef - terminalFrameHeightRef: MutableRef - onMeasured: ( - handle: string, - dims: TerminalViewportDims | null | undefined, - frameHeight: number - ) => void -} - -/** Measures the phone viewport once per route; the dims then ride every subscribe so the host fits the PTY itself. */ -export async function measureTerminalViewportOnce(args: TerminalViewportOnceArgs): Promise { - if (args.viewportMeasuredRef.current) { - return - } - const ref = args.ref - if (ref && !args.documentHasTerminal) { - // Why: without this the first subscribe goes dimensionless, paints at the desktop's size, then resubscribes. - ref.init(EMPTY_TERMINAL_COLS, EMPTY_TERMINAL_ROWS, '') - await ref.awaitReady() - } - const dims = await ref?.measureFitDimensions(args.terminalFrameHeightRef.current || undefined) - args.onMeasured(args.handle, dims, args.terminalFrameHeightRef.current) - if (dims) { - args.viewportRef.current = dims - args.viewportMeasuredRef.current = true - } -} - -export type FirstSubscribeViewportGateArgs = { - handle: string - covered: boolean - viewportMeasured: boolean - /** Handles whose current document has already been subscribed; cleared when that document goes away. */ - subscribedDocuments: Set - subscribingHandles: Set - subscribeSeq: ReadonlyMap - measure: (handle: string) => Promise - subscribe: (handle: string) => void -} - -/** - * Holds a document's first subscribe until the route's viewport is measured, so the host - * serializes the snapshot at the phone's size. Returns true when it took over the subscribe. - */ -export function deferFirstSubscribeUntilViewportMeasured( - args: FirstSubscribeViewportGateArgs -): boolean { - const { handle } = args - // A covered lease has no document to measure and asks for no viewport. - if (args.covered) { - return false - } - const firstForDocument = !args.subscribedDocuments.has(handle) - args.subscribedDocuments.add(handle) - if (!firstForDocument || args.viewportMeasured) { - return false - } - args.subscribingHandles.add(handle) - const seq = args.subscribeSeq.get(handle) - const resume = (): void => { - // Why: unsubscribeTerminal bumps the seq and clears the in-flight mark; a torn-down handle stays down. - if (args.subscribeSeq.get(handle) !== seq || !args.subscribingHandles.has(handle)) { - return - } - args.subscribingHandles.delete(handle) - args.subscribe(handle) - } - void args.measure(handle).then(resume, resume) - return true -} diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts index 14f11b15c40..752d2016979 100644 --- a/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts @@ -252,7 +252,7 @@ describe('runTerminalViewportFitPass', () => { const scheduled: { fn: () => void; ms: number }[] = [] const webView = { awaitReady: () => Promise.resolve(), - measureFitDimensions: () => Promise.resolve(overrides.measured ?? PHONE) + fitDimensions: () => (overrides.measured === undefined ? PHONE : overrides.measured) } const unsubscribeTerminal = vi.fn((handle: string) => { terminalUnsubsRef.current.delete(handle) @@ -275,7 +275,7 @@ describe('runTerminalViewportFitPass', () => { subscribeSeqRef: { current: new Map([[HANDLE, 1]]) }, initializedHandlesRef: { current: new Set([HANDLE]) }, terminalUnsubsRef, - terminalFrameHeightRef: { current: 0 }, + terminalFrameRef: { current: { width: 390, height: 600 } }, getTerminalRef: () => webView, unsubscribeTerminal, subscribeToTerminal, diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts index 9113e84f1f4..f0fc70a1333 100644 --- a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts @@ -2,6 +2,7 @@ * frame dims can never equal the phone viewport must not re-arm the stream * forever — it broke gesture recognition and drained battery at ~25 cycles/s. */ +import type { TerminalFrame } from '../terminal/terminal-webview-messages' import type { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' export const MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS = 3 @@ -177,7 +178,7 @@ export type MutableRef = { current: T } type TerminalFitWebView = { awaitReady: () => Promise - measureFitDimensions: (frameHeight?: number) => Promise + fitDimensions: (frame: { width: number; height: number }) => TerminalViewportDims | null } export type TerminalViewportFitPassArgs = { @@ -197,7 +198,7 @@ export type TerminalViewportFitPassArgs = { subscribeSeqRef: MutableRef> initializedHandlesRef: MutableRef> terminalUnsubsRef: MutableRef void>> - terminalFrameHeightRef: MutableRef + terminalFrameRef: MutableRef getTerminalRef: (handle: string | null) => TerminalFitWebView | undefined unsubscribeTerminal: (handle: string) => void subscribeToTerminal: (handle: string) => void @@ -235,7 +236,7 @@ export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): v // Why: a subscribe that carried a viewport already told the host one; a fresh measure that matches it needs no round trip. const viewportWasMeasured = args.viewportMeasuredRef.current || args.sentViewport != null void (async () => { - // Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06). + // Why: wait for init()'s rAF chain, which reports the box the fit reads (log dump 2026-05-06). await args.getTerminalRef(handle)?.awaitReady() if ( args.subscribeSeqRef.current.get(handle) !== seq || @@ -243,17 +244,9 @@ export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): v ) { return } - const dims = await args - .getTerminalRef(handle) - ?.measureFitDimensions(args.terminalFrameHeightRef.current || undefined) - // Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation. - if ( - args.subscribeSeqRef.current.get(handle) !== seq || - !budget.isRetryGenerationCurrent(handle, retryGeneration) - ) { - return - } - if (!args.getTerminalRef(handle) || !dims) { + const frame = args.terminalFrameRef.current + const dims = frame ? args.getTerminalRef(handle)?.fitDimensions(frame) : null + if (!dims) { return } args.viewportRef.current = dims 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 33e99bfb603..3546a568e73 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 @@ -144,6 +144,38 @@ describe('useMobileNativeChatTurnDisclosure', () => { } }) + it('keeps the live bar on every render until it settles in place', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(1_000) + const messages = [userMessage('u1')] + const seen: unknown[] = [] + function Recorder({ isWorking }: { isWorking: boolean }): React.JSX.Element { + const disclosure = useMobileNativeChatTurnDisclosure({ + messages, + enabled: true, + isWorking, + scopeKey: 'host\0worktree\0tab-a' + }) + seen.push(disclosure.resolveRow(0, messages[0]).turnStatus) + return createElement('result', { disclosure }) + } + act(() => { + renderer = create(createElement(Recorder, { isWorking: true })) + }) + vi.setSystemTime(6_000) + seen.length = 0 + // No host duration for this turn: the settle is stamped locally, one pass later. + act(() => { + renderer?.update(createElement(Recorder, { isWorking: false })) + }) + expect(seen).not.toContain(null) + expect(seen.at(-1)).toEqual({ startedAt: 1_000, thinking: false, workedSeconds: 5 }) + } finally { + vi.useRealTimers() + } + }) + it('suppresses local duration when the host explicitly cannot verify the end', () => { vi.useFakeTimers() try { 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 a67a6a88663..4e236d648be 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -104,12 +104,13 @@ export function useMobileNativeChatTurnDisclosure({ const resolveRow = useCallback( (index: number, message: NativeChatMessage): MobileNativeChatTurnRow => { const turnKey = turnKeys[index] + // The live turn's bar carries its running clock; it settles in place. const turnStatus = - !enabled || message.role !== 'user' + !enabled || message.role !== 'user' || !turnKey ? null - : turnKey - ? (completedByTurn[turnKey] ?? null) - : null + : turnKey === activeTurnKey + ? active + : (completedByTurn[turnKey] ?? null) return { turnStatus, turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false, @@ -126,7 +127,7 @@ export function useMobileNativeChatTurnDisclosure({ (turnKey === undefined && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY)) } }, - [turnKeys, enabled, activeTurnKey, completedByTurn, expandedTurnIds, isWorking] + [turnKeys, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, isWorking] ) return { diff --git a/mobile/src/session/use-mobile-session-keyboard-state.test.tsx b/mobile/src/session/use-mobile-session-keyboard-state.test.tsx index e1f12b7414d..96ab0a8d4ab 100644 --- a/mobile/src/session/use-mobile-session-keyboard-state.test.tsx +++ b/mobile/src/session/use-mobile-session-keyboard-state.test.tsx @@ -73,8 +73,7 @@ function Harness(): null { initializedHandlesRef: { current: new Set() }, showNativeChatRef: { current: false }, subscribeToTerminal: () => {}, - terminalFrameHeightRef: { current: 0 }, - terminalFrameWidth: 0, + terminalFrameRef: { current: null }, terminalRefs: { current: new Map() }, unsubscribeTerminal: () => {}, viewportMeasuredRef: { current: false }, @@ -121,10 +120,10 @@ describe('what the session screen hears about the keyboard', () => { expect(harness.notifyKeyboardVisibility).toHaveBeenLastCalledWith(false) }) - it('holds the refit off on the page, where the keyboard is open and covers nothing', async () => { - // Inside the shell the WebView is already shortened, so the seam reports no occlusion: the - // dock needs no lift. Refitting on that height change would reflow the desktop PTY on every - // keyboard open, which is the reflow the visibility flag exists to defer. + it('holds the refit off for a keyboard that is open and covers nothing', async () => { + // A floating keyboard reports no occlusion, so the dock needs no lift. Refitting on that height + // change would reflow the desktop PTY on every keyboard open, which is the reflow the + // visibility flag exists to defer. const tree = await mount() harness.keyboard = { height: 0, visible: true } await rerender(tree) diff --git a/mobile/src/session/use-mobile-session-keyboard-state.ts b/mobile/src/session/use-mobile-session-keyboard-state.ts index 6d9ec467a35..cfc5f9af011 100644 --- a/mobile/src/session/use-mobile-session-keyboard-state.ts +++ b/mobile/src/session/use-mobile-session-keyboard-state.ts @@ -31,8 +31,7 @@ export type MobileSessionKeyboardScope = Pick< | 'tabStripOffsetRef' | 'tabStripRef' | 'tabStripViewportWidthRef' - | 'terminalFrameHeightRef' - | 'terminalFrameWidth' + | 'terminalFrameRef' | 'terminalRefs' | 'terminals' | 'terminalTextScale' @@ -67,17 +66,21 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope) terminalRefs, initializedHandlesRef, activeHandleRef, - terminalFrameHeightRef, - terminalFrameWidth, + terminalFrameRef, showNativeChatRef, unsubscribeTerminal, subscribeToTerminal } = scope // Why: non-subscribe layout refits (tab strip, fold, rotation) live in a dedicated hook — see terminal-viewport-refit.ts. - const { notifyTerminalFrameHeight, notifyKeyboardVisibility } = useTerminalViewportRefit({ + const { + notifyTerminalFrameHeight, + notifyTerminalFrameWidth, + notifyKeyboardVisibility, + notifyTerminalCellBoxChange + } = useTerminalViewportRefit({ activeHandleRef, terminalRefs, - terminalFrameHeightRef, + terminalFrameRef, viewportRef, viewportMeasuredRef, nativeChatCoveredRef: showNativeChatRef, @@ -87,7 +90,6 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope) connState, tabStripVisible: terminals.length > 1, textScale: terminalTextScale, - terminalFrameWidth, unsubscribeTerminal, subscribeToTerminal }) @@ -151,7 +153,9 @@ export function useMobileSessionKeyboardState(scope: MobileSessionKeyboardScope) }, [router]) return { notifyTerminalFrameHeight, + notifyTerminalFrameWidth, notifyKeyboardVisibility, + notifyTerminalCellBoxChange, scrollActiveTabIntoView, handleDeleteCustomKey, handleManageShortcuts diff --git a/mobile/src/session/use-mobile-session-presentation.ts b/mobile/src/session/use-mobile-session-presentation.ts index 2bea9158db0..dca323343b0 100644 --- a/mobile/src/session/use-mobile-session-presentation.ts +++ b/mobile/src/session/use-mobile-session-presentation.ts @@ -1,9 +1,9 @@ -import { Platform } from 'react-native' import { classifyConnection, verdictDisplayLabel } from '../transport/connection-health' import { computeActiveTerminalKeyboardLift } from '../terminal/terminal-keyboard-avoidance-lift' import { useInitialSessionTerminalAutoCreate } from './use-initial-session-terminal-autocreate' import { MOBILE_SESSION_STATUS_LABELS } from './mobile-session-route-helpers' import type { MobileSessionBulkCloseModel } from './use-mobile-session-bulk-close' +import { hostOs } from '../platform/host-os' export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) { const { @@ -25,7 +25,7 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) toastOpacityRef, hostEndpoint, initialSessionAutoCreateRef, - terminalFrameHeightRef, + terminalFrameRef, handleCreateTerminal, visibleTabs, forceReconnectHost @@ -77,14 +77,14 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) // Why: iOS keyboard height includes the home-indicator inset; Android IME height does not. const keyboardLift = keyboardHeight > 0 - ? Platform.OS === 'ios' + ? hostOs() === 'ios' ? Math.max(0, keyboardHeight - insets.bottom) : keyboardHeight : 0 const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({ keyboardLift, metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined, - terminalFrameHeight: terminalFrameHeightRef.current + terminalFrameHeight: terminalFrameRef.current?.height ?? 0 }) const toastAnimatedStyle = { opacity: toastOpacityRef.current, diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts index 5e4196e1500..b19213f5356 100644 --- a/mobile/src/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts @@ -1,10 +1,12 @@ import { useState, useRef, useCallback } from 'react' -import { Platform, type Keyboard, type TextInput } from 'react-native' +import type { TerminalFrame } from '../terminal/terminal-webview-messages' +import type { Keyboard, TextInput } from 'react-native' import { useFocusEffect } from 'expo-router' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import type { TerminalModes, TerminalWebViewHandle } from '../terminal/terminal-webview-contract' import { useTerminalLiveInputFocus } from '../terminal/use-terminal-live-input-focus' +import { reopensFocusedInputWhenKeyboardHidden } from '../terminal/terminal-live-input-keyboard-reopen' import type { TerminalLiveInputSender } from '../terminal/terminal-live-input-sender' import { useTerminalLiveInputCommit } from '../terminal/use-terminal-live-input-commit' import { resolveMobileTerminalInputGate } from '../terminal/terminal-input-connection-gate' @@ -76,8 +78,6 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget()) // Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered. const webReadyHandlesRef = useRef>(new Set()) - // Why: a document's first subscribe waits for the viewport measure (see mobile-terminal-first-subscribe-viewport.ts). - const subscribedDocumentsRef = useRef>(new Set()) const activeHandleRef = useRef(null) const bufferedTerminalDraftState = useBufferedTerminalDrafts({ activeHandle, activeHandleRef }) const reconcileBufferedDraftsRef = useRef(bufferedTerminalDraftState.reconcileTerminalTabs) @@ -103,10 +103,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM // Why: highest applyLayout seq seen per handle; drop older scrollback/resized as stale, but a >20 gap resets (fresh subscription/server restart). const layoutSeqRef = useRef>(new Map()) const sendingRef = useRef(false) - // Why: exact terminal-frame height for measureFitDimensions; window.innerHeight can overstate the visible area. - const terminalFrameHeightRef = useRef(0) - // Why: sidebar resizes change the terminal frame width without a window-dim change; track it so the refit hook re-fits (see terminal-viewport-refit.ts). - const [terminalFrameWidth, setTerminalFrameWidth] = useState(0) + // Why: the terminal frame React Native laid out, unrounded, for every fit; window.innerHeight can + // overstate the visible area. Null until the frame's first layout. + const terminalFrameRef = useRef(null) const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null const { clearPendingLiveInputCommit, @@ -144,7 +143,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM lifecycleIdentity: client, lifecycleKey: JSON.stringify([hostId, worktreeId, connState]), liveInputEnabled, - reopenFocusedInputWhenKeyboardHidden: Platform.OS === 'android', + reopenFocusedInputWhenKeyboardHidden: reopensFocusedInputWhenKeyboardHidden(), timerRef: liveInputFocusTimerRef }) useFocusEffect( @@ -182,7 +181,6 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM terminalDiagnosticsRef, viewportResubscribeBudgetRef, webReadyHandlesRef, - subscribedDocumentsRef, activeHandleRef, activeSessionTabTypeRef, pendingActiveSessionTabIdRef, @@ -198,9 +196,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM delayedActionTimersRef, layoutSeqRef, sendingRef, - terminalFrameHeightRef, - terminalFrameWidth, - setTerminalFrameWidth, + terminalFrameRef, activeSessionTab, clearPendingLiveInputCommit, flushPendingLiveInputBeforeExternalSend, diff --git a/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts b/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts index efb482c8c8f..306adec9b33 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription-foundation.ts @@ -1,5 +1,4 @@ import { useRef, useCallback } from 'react' -import { measureTerminalViewportOnce } from './mobile-terminal-first-subscribe-viewport' import type { MobileSessionNativeChatDictationModel } from './use-mobile-session-native-chat-dictation' export function useMobileSessionTerminalSubscriptionFoundation( @@ -9,8 +8,6 @@ export function useMobileSessionTerminalSubscriptionFoundation( setCoveredStreamRevision, setTerminalKeyboardMetrics, terminalCwdRef, - viewportRef, - viewportMeasuredRef, terminalRefs, terminalUnsubsRef, subscribingHandlesRef, @@ -19,11 +16,9 @@ export function useMobileSessionTerminalSubscriptionFoundation( terminalDiagnosticsRef, viewportResubscribeBudgetRef, webReadyHandlesRef, - subscribedDocumentsRef, activeHandleRef, subscribeSeqRef, layoutSeqRef, - terminalFrameHeightRef, nativeChatInputLeaseReadyRef, clearNativeChatInputLease, showNativeChatRef @@ -77,7 +72,6 @@ export function useMobileSessionTerminalSubscriptionFoundation( terminalDiagnosticsRef.current.clearTerminalCache() viewportResubscribeBudgetRef.current.clear() webReadyHandlesRef.current.clear() - subscribedDocumentsRef.current.clear() subscribeSeqRef.current.clear() layoutSeqRef.current.clear() terminalCwdRef.current.clear() @@ -86,27 +80,11 @@ export function useMobileSessionTerminalSubscriptionFoundation( term.clear() } }, [clearNativeChatInputLease]) - - const measureViewportOnce = useCallback( - (handle: string) => - measureTerminalViewportOnce({ - handle, - ref: getTerminalRef(handle), - documentHasTerminal: initializedHandlesRef.current.has(handle), - viewportRef, - viewportMeasuredRef, - terminalFrameHeightRef, - onMeasured: (measuredHandle, dims, frameHeight) => - terminalDiagnosticsRef.current.viewportMeasured(measuredHandle, dims, frameHeight) - }), - [getTerminalRef] - ) return { getTerminalRef, unsubscribeTerminal, unsubscribeTerminalRef, - clearTerminalCache, - measureViewportOnce + clearTerminalCache } } diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx b/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx new file mode 100644 index 00000000000..ef2954e183b --- /dev/null +++ b/mobile/src/session/use-mobile-session-terminal-subscription.test.tsx @@ -0,0 +1,230 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract' +import { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' +import { TerminalViewportResubscribeBudget } from './mobile-terminal-viewport-resubscribe' +import type { MobileSessionTerminalSubscriptionFoundationModel } from './use-mobile-session-terminal-subscription-foundation' +import { useMobileSessionTerminalSubscription } from './use-mobile-session-terminal-subscription' +import type { MobileSessionTabSwitchingModel } from './use-mobile-session-tab-switching' +import { useMobileSessionTerminalWebview } from './use-mobile-session-terminal-webview' + +const HANDLE = 'term-1' +const PHONE = { cols: 55, rows: 44 } + +type StreamHandler = (result: unknown) => void + +/** The route's subscribe, driven end to end against a recording client and terminal. */ +function subscriptionHarness(opts: { + fit: typeof PHONE | null + webReady: boolean + frameWidth?: number + viewport?: typeof PHONE +}) { + let fit = opts.fit + const order: string[] = [] + const handlers: StreamHandler[] = [] + const terminal: TerminalWebViewHandle = { + prepareForForegroundRecovery: vi.fn(), + write: vi.fn(), + init: vi.fn(({ cols, rows }: { cols: number; rows: number }) => { + order.push(`init ${cols}x${rows}`) + // An init's document reports its laid-out box before its ready. + fit = fit ?? PHONE + }), + resize: vi.fn(), + reflow: vi.fn(), + clear: vi.fn(), + fitDimensions: vi.fn(() => fit), + resetZoom: vi.fn(), + cancelSelect: vi.fn(), + doSelectAll: vi.fn(), + awaitReady: vi.fn(async () => {}) + } + const terminalUnsubsRef = { current: new Map void>() } + const subscribeSeqRef = { current: new Map() } + const subscribingHandlesRef = { current: new Set() } + const initializedHandlesRef = { current: new Set() } + const webReadyHandlesRef = { current: new Set(opts.webReady ? [HANDLE] : []) } + const terminalFrameRef: { current: { width: number; height: number } | null } = { + current: opts.frameWidth === 0 ? null : { width: opts.frameWidth ?? 427, height: 751 } + } + const fields = { + client: { + subscribe: vi.fn( + (_method: string, params: Record, onData: StreamHandler) => { + order.push(`subscribe ${JSON.stringify(params.viewport ?? null)}`) + handlers.push(onData) + return () => {} + } + ) + }, + clientId: 'client-1', + setTerminalModes: vi.fn(), + terminalCwdRef: { current: new Map() }, + viewportRef: { current: opts.viewport ?? null }, + viewportMeasuredRef: { current: opts.viewport !== undefined }, + terminalUnsubsRef, + subscribingHandlesRef, + leaseOnlyHandlesRef: { current: new Set() }, + initializedHandlesRef, + terminalDiagnosticsRef: { current: new MobileTerminalDiagnostics() }, + viewportResubscribeBudgetRef: { current: new TerminalViewportResubscribeBudget() }, + webReadyHandlesRef, + activeHandleRef: { current: HANDLE }, + subscribeSeqRef, + layoutSeqRef: { current: new Map() }, + terminalFrameRef, + scheduleDelayedAction: vi.fn(), + showToast: vi.fn(), + markNativeChatInputLeaseReady: vi.fn(), + showNativeChatRef: { current: false }, + getTerminalRef: (handle: string | null) => (handle === HANDLE ? terminal : undefined), + unsubscribeTerminal: (handle: string) => { + terminalUnsubsRef.current.delete(handle) + subscribingHandlesRef.current.delete(handle) + subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) + }, + unsubscribeTerminalRef: { current: vi.fn() }, + signalTerminalInventoryRecovery: vi.fn(), + terminalRefs: { current: new Map([[HANDLE, terminal]]) }, + pendingActiveTerminalHandleRef: { current: null }, + nativeChatStream: { notifyWebReady: vi.fn() }, + terminalGestureInputBucketsRef: { current: new Map() }, + terminalGestureInputQueuesRef: { current: new Map() }, + terminalGestureInputInFlightRef: { current: new Set() }, + activeSessionTab: null, + markdownDocs: new Map(), + fileDocs: new Map(), + readMarkdownTab: vi.fn(), + readFileTab: vi.fn(), + notifyTerminalFrameHeight: vi.fn(), + notifyTerminalFrameWidth: vi.fn() + } + let subscribe: ((handle: string) => void) | undefined + let webReady: ((handle: string) => void) | undefined + let notifyFrame: ((frame: { width: number; height: number }) => void) | undefined + function Probe() { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook destructures only the fields built above. + const scope = fields as unknown as MobileSessionTerminalSubscriptionFoundationModel + subscribe = useMobileSessionTerminalSubscription(scope).subscribeToTerminal + const withSubscribe = { ...fields, subscribeToTerminal: subscribe } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the web-ready handler reads only the fields built above. + const webviewScope = withSubscribe as unknown as MobileSessionTabSwitchingModel + const webview = useMobileSessionTerminalWebview(webviewScope) + webReady = webview.handleTerminalWebReady + notifyFrame = webview.notifyTerminalFrame + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + const scrollback = (index: number, cols: number, rows: number) => + act(() => { + handlers[index]({ type: 'scrollback', seq: 1, cols, rows, serialized: 'x' }) + }) + return { + order, + subscribe: () => act(() => subscribe!(HANDLE)), + scrollback, + terminal, + // The document's web-ready, carrying the box xterm laid out. + documentReady: () => { + act(() => webReady!(HANDLE)) + }, + fields, + terminalFrameRef, + // The frame's onLayout. + layOutFrame: (width: number, height = 751) => act(() => notifyFrame!({ width, height })) + } +} + +let renderer: ReactTestRenderer | undefined +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined +}) + +describe('a terminal first subscribe', () => { + it('does not subscribe before its document is ready, then subscribes once from its box', async () => { + const harness = subscriptionHarness({ fit: PHONE, webReady: false }) + harness.subscribe() + expect(harness.order).toEqual([]) + harness.documentReady() + expect(harness.order).toEqual(['subscribe {"cols":55,"rows":44}']) + harness.scrollback(0, PHONE.cols, PHONE.rows) + await act(async () => {}) + expect(harness.order).toEqual(['subscribe {"cols":55,"rows":44}', 'init 55x44']) + }) + + it('resubscribes a document reloaded after its first ready, which lost its terminal', async () => { + const harness = subscriptionHarness({ fit: PHONE, webReady: false }) + harness.documentReady() + harness.scrollback(0, PHONE.cols, PHONE.rows) + await act(async () => {}) + harness.documentReady() + harness.scrollback(1, PHONE.cols, PHONE.rows) + await act(async () => {}) + expect(harness.order.filter((step) => step.startsWith('init'))).toEqual([ + 'init 55x44', + 'init 55x44' + ]) + }) + + it('sizes from the reported box against the laid-out frame', () => { + const harness = subscriptionHarness({ fit: PHONE, webReady: true, frameWidth: 427.5 }) + harness.subscribe() + expect(harness.terminal.fitDimensions).toHaveBeenCalledWith({ width: 427.5, height: 751 }) + expect(harness.order).toEqual(['subscribe {"cols":55,"rows":44}']) + }) + + it('keeps a measured viewport rather than fitting again', () => { + const measured = { cols: 60, rows: 40 } + const harness = subscriptionHarness({ fit: PHONE, webReady: true, viewport: measured }) + harness.subscribe() + expect(harness.terminal.fitDimensions).not.toHaveBeenCalled() + expect(harness.order).toEqual(['subscribe {"cols":60,"rows":40}']) + }) + + it('holds a ready document without a box until its frame is laid out', () => { + const harness = subscriptionHarness({ fit: null, webReady: true, frameWidth: 0 }) + harness.subscribe() + expect(harness.order).toEqual([]) + // The frame's first layout subscribes the document held back for it. + harness.layOutFrame(427) + expect(harness.order).toEqual(['subscribe null']) + }) + + it('keeps one frame, notifies a new width, and subscribes on the first layout only', () => { + const harness = subscriptionHarness({ fit: null, webReady: true, frameWidth: 0 }) + const { notifyTerminalFrameHeight, notifyTerminalFrameWidth } = harness.fields + harness.layOutFrame(0, 0) + expect(harness.terminalFrameRef.current).toBeNull() + harness.layOutFrame(427, 751) + harness.layOutFrame(427, 700) + expect(harness.order).toEqual(['subscribe null']) + expect(notifyTerminalFrameWidth).not.toHaveBeenCalled() + // A hidden 0x0 layout keeps the box it was laid out at. + harness.layOutFrame(0, 0) + expect(harness.terminalFrameRef.current).toEqual({ width: 427, height: 700 }) + harness.layOutFrame(360.5, 700) + expect(harness.terminalFrameRef.current).toEqual({ width: 360.5, height: 700 }) + expect(notifyTerminalFrameWidth).toHaveBeenCalledTimes(1) + expect(notifyTerminalFrameHeight.mock.calls.map(([height]) => height)).toEqual([ + 0, 751, 700, 0, 700 + ]) + }) + + it('goes without dims when no cell box was reported, and the fit pass resubscribes once', async () => { + const harness = subscriptionHarness({ fit: null, webReady: true }) + harness.subscribe() + expect(harness.order).toEqual(['subscribe null']) + harness.scrollback(0, 120, 40) + await vi.waitFor(() => expect(harness.order).toHaveLength(3)) + expect(harness.order).toEqual([ + 'subscribe null', + 'init 120x40', + 'subscribe {"cols":55,"rows":44}' + ]) + }) +}) diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index a440c22e319..73af79a959d 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -1,7 +1,6 @@ import { useCallback } from 'react' import { isTerminalOscLinkRanges } from '../../../src/shared/terminal-osc-link-ranges' import * as nativeChatTerminalStream from './mobile-native-chat-terminal-stream' -import { deferFirstSubscribeUntilViewportMeasured } from './mobile-terminal-first-subscribe-viewport' import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe' import { mobileTerminalSnapshotByteBudget } from './terminal-snapshot-byte-budget' import { @@ -32,11 +31,10 @@ export function useMobileSessionTerminalSubscription( terminalDiagnosticsRef, viewportResubscribeBudgetRef, webReadyHandlesRef, - subscribedDocumentsRef, activeHandleRef, subscribeSeqRef, layoutSeqRef, - terminalFrameHeightRef, + terminalFrameRef, scheduleDelayedAction, showToast, markNativeChatInputLeaseReady, @@ -44,7 +42,6 @@ export function useMobileSessionTerminalSubscription( getTerminalRef, unsubscribeTerminal, unsubscribeTerminalRef, - measureViewportOnce, signalTerminalInventoryRecovery } = scope const subscribeToTerminal = useCallback( @@ -75,7 +72,8 @@ export function useMobileSessionTerminalSubscription( ) // Why: a native-chat-covered terminal has no mounted webview, so only gate on the webview when not covered. if (!covered) { - if (!getTerminalRef(handle)) { + const ref = getTerminalRef(handle) + if (!ref) { logSkippedGate('no-webview-ref') return } @@ -83,22 +81,21 @@ export function useMobileSessionTerminalSubscription( logSkippedGate('webview-not-ready') return } - } - - if ( - deferFirstSubscribeUntilViewportMeasured({ - handle, - covered, - viewportMeasured: viewportMeasuredRef.current, - subscribedDocuments: subscribedDocumentsRef.current, - subscribingHandles: subscribingHandlesRef.current, - subscribeSeq: subscribeSeqRef.current, - measure: measureViewportOnce, - subscribe: subscribeToTerminal - }) - ) { - logSkippedGate('measuring-viewport') - return + const frame = terminalFrameRef.current + if (!viewportMeasuredRef.current) { + // Why: the frame's first layout subscribes it; going now would miss the dims (page web-ready precedes it). + if (!frame) { + logSkippedGate('frame-not-laid-out') + return + } + // Why: sized from the box the ready document reported, the host serializes the snapshot at the phone's size. + const dims = ref.fitDimensions(frame) + diagnostics.viewportMeasured(handle, dims, frame.height) + if (dims) { + viewportRef.current = dims + viewportMeasuredRef.current = true + } + } } subscribingHandlesRef.current.add(handle) @@ -198,7 +195,7 @@ export function useMobileSessionTerminalSubscription( }) return } - ref.init(cols, rows, initialData, false, oscLinks) + ref.init({ cols, rows, initialData, oscLinks, frame: terminalFrameRef.current }) initializedHandlesRef.current.add(handle) if (data.displayMode) { const displayMode = data.displayMode as MobileDisplayMode @@ -209,8 +206,8 @@ export function useMobileSessionTerminalSubscription( } // Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM. scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) - // Why: first subscribe has no viewport (xterm not loaded yet), so measure after init - // and resubscribe so the server can phone-fit — bounded per handle so a + // Why: a subscribe without a viewport (no reported cell box) measures after init and + // resubscribes so the server can phone-fit — bounded per handle so a // non-converging host degrades visibly instead of hot-looping (STA-3337). runTerminalViewportFitPass({ handle, @@ -225,7 +222,7 @@ export function useMobileSessionTerminalSubscription( subscribeSeqRef, initializedHandlesRef, terminalUnsubsRef, - terminalFrameHeightRef, + terminalFrameRef, getTerminalRef, unsubscribeTerminal, subscribeToTerminal, @@ -266,9 +263,16 @@ export function useMobileSessionTerminalSubscription( diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null) const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined if (serialized != null) { - getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks) + getTerminalRef(handle)?.init({ + cols, + rows, + initialData: serialized, + preserveScroll: true, + oscLinks, + frame: terminalFrameRef.current + }) } else { - getTerminalRef(handle)?.resize(cols, rows) + getTerminalRef(handle)?.resize(cols, rows, terminalFrameRef.current) } if (data.displayMode) { const displayMode = data.displayMode as MobileDisplayMode @@ -298,7 +302,6 @@ export function useMobileSessionTerminalSubscription( clientId, getTerminalRef, markNativeChatInputLeaseReady, - measureViewportOnce, scheduleDelayedAction, showToast, signalTerminalInventoryRecovery diff --git a/mobile/src/session/use-mobile-session-terminal-webview.test.tsx b/mobile/src/session/use-mobile-session-terminal-webview.test.tsx deleted file mode 100644 index 5dfede07429..00000000000 --- a/mobile/src/session/use-mobile-session-terminal-webview.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract' -import { deferFirstSubscribeUntilViewportMeasured } from './mobile-terminal-first-subscribe-viewport' -import { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' -import type { MobileSessionTabSwitchingModel } from './use-mobile-session-tab-switching' -import { useMobileSessionTerminalWebview } from './use-mobile-session-terminal-webview' - -const HANDLE = 'term-1' - -function terminalHandle(): TerminalWebViewHandle { - return { - prepareForForegroundRecovery: vi.fn(), - write: vi.fn(), - init: vi.fn(), - resize: vi.fn(), - reflow: vi.fn(), - clear: vi.fn(), - measureFitDimensions: vi.fn(async () => null), - resetZoom: vi.fn(), - cancelSelect: vi.fn(), - doSelectAll: vi.fn(), - awaitReady: vi.fn(async () => {}) - } -} - -function makeScope() { - const fields = { - markdownDocs: new Map(), - fileDocs: new Map(), - terminalGestureInputBucketsRef: { current: new Map() }, - terminalGestureInputQueuesRef: { current: new Map() }, - terminalGestureInputInFlightRef: { current: new Set() }, - terminalRefs: { current: new Map() }, - terminalUnsubsRef: { current: new Map void>() }, - initializedHandlesRef: { current: new Set() }, - terminalDiagnosticsRef: { current: new MobileTerminalDiagnostics() }, - webReadyHandlesRef: { current: new Set() }, - subscribedDocumentsRef: { current: new Set() }, - activeHandleRef: { current: HANDLE }, - pendingActiveTerminalHandleRef: { current: null }, - activeSessionTab: null, - unsubscribeTerminal: vi.fn(), - subscribeToTerminal: vi.fn(), - nativeChatStream: { notifyWebReady: vi.fn() }, - readMarkdownTab: vi.fn(), - readFileTab: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook destructures only the fields built above. - return { fields, scope: fields as unknown as MobileSessionTabSwitchingModel } -} - -let renderer: ReactTestRenderer | undefined -afterEach(() => { - act(() => renderer?.unmount()) - renderer = undefined -}) - -function renderWebviewHook(scope: MobileSessionTabSwitchingModel) { - let model: ReturnType | undefined - function Probe() { - model = useMobileSessionTerminalWebview(scope) - return null - } - act(() => { - renderer = create(createElement(Probe)) - }) - if (!model) { - throw new Error('hook did not render') - } - return model -} - -// The gate as subscribeToTerminal calls it once a refit has cleared the measured flag. -function gateWouldReopen(fields: ReturnType['fields']) { - return deferFirstSubscribeUntilViewportMeasured({ - handle: HANDLE, - covered: false, - viewportMeasured: false, - subscribedDocuments: fields.subscribedDocumentsRef.current, - subscribingHandles: new Set(), - subscribeSeq: new Map([[HANDLE, 1]]), - measure: vi.fn(async () => {}), - subscribe: vi.fn() - }) -} - -describe('terminal document lifetime for the first-subscribe gate', () => { - it('keeps a live document marked when its handle re-attaches for a theme or text-size change', () => { - const { fields, scope } = makeScope() - const model = renderWebviewHook(scope) - model.setTerminalWebViewRef(HANDLE, terminalHandle()) - model.handleTerminalWebReady(HANDLE) - fields.subscribedDocumentsRef.current.add(HANDLE) - // The controller's handle memo changes with theme and text scale: null, then the new handle. - model.setTerminalWebViewRef(HANDLE, null) - model.setTerminalWebViewRef(HANDLE, terminalHandle()) - expect(gateWouldReopen(fields)).toBe(false) - }) - - it('forgets the mark when a new document announces itself', () => { - const { fields, scope } = makeScope() - const model = renderWebviewHook(scope) - model.setTerminalWebViewRef(HANDLE, terminalHandle()) - fields.subscribedDocumentsRef.current.add(HANDLE) - model.handleTerminalWebReady(HANDLE) - expect(fields.subscribedDocumentsRef.current.has(HANDLE)).toBe(false) - }) -}) diff --git a/mobile/src/session/use-mobile-session-terminal-webview.ts b/mobile/src/session/use-mobile-session-terminal-webview.ts index 59ffcccbca6..484930f1218 100644 --- a/mobile/src/session/use-mobile-session-terminal-webview.ts +++ b/mobile/src/session/use-mobile-session-terminal-webview.ts @@ -1,5 +1,6 @@ import { useEffect, useCallback } from 'react' import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract' +import type { TerminalFrame } from '../terminal/terminal-webview-messages' import type { MobileSessionTabSwitchingModel } from './use-mobile-session-tab-switching' export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitchingModel) { @@ -14,7 +15,6 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching initializedHandlesRef, terminalDiagnosticsRef, webReadyHandlesRef, - subscribedDocumentsRef, activeHandleRef, pendingActiveTerminalHandleRef, activeSessionTab, @@ -22,7 +22,10 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching subscribeToTerminal, nativeChatStream, readMarkdownTab, - readFileTab + readFileTab, + terminalFrameRef, + notifyTerminalFrameHeight, + notifyTerminalFrameWidth } = scope // Why: only store the ref; subscribe on web-ready to avoid the blank-terminal race (init queued before xterm.js loaded). const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => { @@ -44,8 +47,6 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching const handleTerminalWebReady = useCallback( (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) - // Why: web-ready announces a new document; the handle also re-attaches on theme or text-size changes, which keep the document. - subscribedDocumentsRef.current.delete(handle) webReadyHandlesRef.current.add(handle) nativeChatStream.notifyWebReady(handle, wasAlreadyReady) terminalDiagnosticsRef.current.webViewReady( @@ -65,7 +66,7 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching // Why: a just-created tab can lose activeHandleRef to a lagging snapshot; honor the pending marker so its web-ready subscribe still fires. const isIntendedActive = handle === activeHandleRef.current || handle === pendingActiveTerminalHandleRef.current - // Why: subscribeToTerminal measures the viewport first for a fresh document, so the dims ride this subscribe. + // Why: web-ready carried the cell box xterm laid out, so subscribeToTerminal sizes this subscribe. if (isIntendedActive && !terminalUnsubsRef.current.has(handle)) { subscribeToTerminal(handle) } @@ -73,6 +74,39 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching [nativeChatStream, subscribeToTerminal, unsubscribeTerminal] ) + const subscribeIntendedActiveTerminal = useCallback(() => { + const handle = pendingActiveTerminalHandleRef.current ?? activeHandleRef.current + if (handle && !terminalUnsubsRef.current.has(handle)) { + subscribeToTerminal(handle) + } + }, [activeHandleRef, pendingActiveTerminalHandleRef, subscribeToTerminal, terminalUnsubsRef]) + + /** The terminal frame React Native laid out: kept in the one frame ref, then what it changed. */ + const notifyTerminalFrame = useCallback( + (frame: TerminalFrame) => { + // Why: notify height imperatively so dock settling re-fits the PTY without rerendering SessionScreen. + notifyTerminalFrameHeight(Math.round(frame.height)) + // Why: the page reports a hidden frame as 0x0; it keeps the box it was laid out at. + if (frame.width <= 0) { + return + } + const previous = terminalFrameRef.current + terminalFrameRef.current = frame + if (!previous) { + // Why: a ready document held back for its frame subscribes on the first layout. + subscribeIntendedActiveTerminal() + } else if (frame.width !== previous.width) { + notifyTerminalFrameWidth() + } + }, + [ + notifyTerminalFrameHeight, + notifyTerminalFrameWidth, + subscribeIntendedActiveTerminal, + terminalFrameRef + ] + ) + useEffect(() => { if (activeSessionTab?.type !== 'markdown') { return @@ -94,7 +128,8 @@ export function useMobileSessionTerminalWebview(scope: MobileSessionTabSwitching }, [activeSessionTab, fileDocs, readFileTab]) return { setTerminalWebViewRef, - handleTerminalWebReady + handleTerminalWebReady, + notifyTerminalFrame } } diff --git a/mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx new file mode 100644 index 00000000000..cba5ec91ba8 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-hold-refused.test.tsx @@ -0,0 +1,103 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined) +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a literal snapshot frame; the hook reads its type, page and fence. +const SNAPSHOT: AgentSessionSubscribeEvent = { + type: 'snapshot', + sessionId: 'session-1', + fence: 3, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + direction: 'tail', + items: [ + { + itemId: 'msg-1', + revision: 1, + sequence: 1, + observedAt: 10, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'still here' }] } + } + ], + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 1 }, + nextCursor: { epoch: 'epoch-1', sequence: 2 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 1 }, + hasOlder: false, + hasNewer: false + } +} as AgentSessionSubscribeEvent + +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +it('subscribes when an older host refuses the hold, and shows the transcript (P2-24)', async () => { + const sendRequest = vi.fn(async (method: string) => + method === 'agentSession.hold' + ? { + ok: false, + error: { code: 'agent_session_owner_restart_failed', message: 'Codex could not start.' }, + _meta: { runtimeId: 'runtime-1' } + } + : { ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } } + ) + const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + onData(SNAPSHOT) + return () => undefined + }) + // 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 + function Harness(): null { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook's options, of which this test supplies the ones it reads. + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'codex', + onSendError: () => undefined + } as never) + return null + } + + act(() => { + renderer = create(createElement(Harness)) + }) + + await vi.waitFor(() => + expect(subscribe).toHaveBeenCalledWith( + 'agentSession.subscribe', + { sessionId: 'session-1' }, + expect.any(Function) + ) + ) + await vi.waitFor(() => expect(hook?.session.messages.length).toBeGreaterThan(0)) + expect(hook?.session.status).not.toBe('error') +}) diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index 49426004968..d2b6d1a5062 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -19,6 +19,33 @@ 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 +/** + * Opens the transcript stream once the hold settles, either way: a refused hold is an older host + * saying it could not start the agent — which the next send does — never a reason to hide the + * transcript. Returns what ends the stream, opened or not yet. Outside the effect so its cleanup + * rule can see the stream is owned. + */ +function openTranscriptAfterHold( + client: RpcClient, + sessionId: string, + held: Promise, + onFrame: (raw: unknown) => void +): () => void { + let ended = false + let close = (): void => {} + void held + .catch(() => undefined) + .then(() => { + if (!ended) { + close = client.subscribe('agentSession.subscribe', { sessionId }, onFrame) + } + }) + return () => { + ended = true + close() + } +} + function isSubscribeEvent(value: unknown): value is AgentSessionSubscribeEvent { if (typeof value !== 'object' || value === null) { return false @@ -101,39 +128,21 @@ export function useMobileStructuredAgentState(args: { } apply({ type: 'loading' }) const holderId = structuredAgentSessionHolderId('mobile-chat') - let cancelled = false - let unsubscribe = (): void => {} const held = callAgentSession(client, 'agentSession.hold', { sessionId, holderId }) - void held - .then(() => { - if (cancelled) { - return - } - unsubscribe = client.subscribe('agentSession.subscribe', { sessionId }, (raw) => { - if ( - typeof raw === 'object' && - raw !== null && - (raw as { type?: unknown }).type === 'error' - ) { - apply({ type: 'error', message: String((raw as { message?: unknown }).message ?? '') }) - return - } - if (isSubscribeEvent(raw)) { - apply({ type: 'event', event: raw }) - } - }) - }) - .catch((error: unknown) => { - if (!cancelled) { - apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) - } - }) + 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 ?? '') : '' }) + return + } + if (isSubscribeEvent(raw)) { + apply({ type: 'event', event: raw }) + } + }) return () => { - cancelled = true - unsubscribe() + endStream() void held .then(() => callAgentSession( diff --git a/mobile/src/tasks/mobile-tasks-chrome-styles.ts b/mobile/src/tasks/mobile-tasks-chrome-styles.ts index 76601c4f725..3b0c3f2d6e2 100644 --- a/mobile/src/tasks/mobile-tasks-chrome-styles.ts +++ b/mobile/src/tasks/mobile-tasks-chrome-styles.ts @@ -28,6 +28,7 @@ export const mobileTasksChromeStyles = StyleSheet.create({ flex: 1, flexDirection: 'row', alignItems: 'center', + gap: spacing.sm, minWidth: 0 }, title: { diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts index 3ba14c571d6..4eb61e3f1da 100644 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts @@ -113,7 +113,8 @@ const SCREEN_RPC_STATEMENTS = 'dd8f33cb3cf96f5c39abac397cb77e35f59079291033a1866 // Saved Linear selections now accept unknown persisted values; reconciliation tests cover them. const MAIN_REBASED_DECLARATIONS = 'ec77d34712c7c4ab19ac6a4d57878f32d22aba790d2420cc14c2c0f000d120e9' const SCREEN_RPC_SEMANTICS = 'e07a63387d57106483ee703ec6c19dea593e0eca5c651758f42bcb36254850b7' -const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a' +// 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', () => { diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index 46d8dd00886..e1dad2269d6 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -22,9 +22,11 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> ante: 'antigma.ai', trae: 'www.trae.cn', muse: 'dev.meta.ai', + dsh: 'deepseek.com', zcode: 'zcode.z.ai', omp: 'omp.sh', 'prime-agent': 'primeintellect.ai', + qoder: 'qoder.com', gemini: 'gemini.google.com', antigravity: 'antigravity.google', goose: 'goose-docs.ai', @@ -36,6 +38,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> autohand: 'autohand.ai', cline: 'cline.bot', codebuff: 'codebuff.com', + freebuff: 'freebuff.com', 'command-code': 'commandcode.ai', continue: 'continue.dev', cursor: 'cursor.com', diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 88d9da6106a..8c474fcd186 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,10 +1,10 @@ -import { useRef, useCallback, forwardRef, useImperativeHandle } from 'react' +import { useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react' import { Platform, View } from 'react-native' import { WebView, type WebViewMessageEvent } from 'react-native-webview' import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract' import { TerminalWebViewEngineErrorOverlay } from './terminal-webview-engine-error-state' import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles' -import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' +import { xtermWebViewSource } from './terminal-webview-html' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { useTerminalWebViewController } from './use-terminal-webview-controller' @@ -34,6 +34,13 @@ export const TerminalWebView = forwardRef( }) useImperativeHandle(ref, () => handle, [handle]) + // Why: the document builds its terminal before ready, so it needs the text scale before any + // message can reach it; later changes arrive as set-font-scale. + // Why: in the page source rather than injected: Android can run an injected script after the + // document's own, which then starts without them. + const [source] = useState(() => + xtermWebViewSource({ textScale: props.textScale ?? 1, shown: props.shownAtMount ?? true }) + ) const handleMessage = useCallback( (event: WebViewMessageEvent) => { @@ -65,7 +72,7 @@ export const TerminalWebView = forwardRef( ( // The page's answer to the WebView's reload: drop the document and build another one. The host // element is keyed on it so React replaces the div rather than handing back one xterm left in. const [generation, setGeneration] = useState(0) + // Why: every document this view builds starts as the view mounted — its scale, and whether it + // was shown — as the native WebView's pre-content script does; later scales arrive with init. + const [atMount] = useState(() => ({ + textScale: props.textScale ?? 1, + shown: props.shownAtMount ?? true + })) useImperativeHandle(ref, () => handle, [handle]) // In an effect, not during render: React may replay or discard render work, and the document @@ -62,7 +68,6 @@ export const TerminalWebView = forwardRef( useEffect(() => { receiveRef.current = receive }, [receive]) - useEffect(() => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: react-native-web renders View as a div and forwards the ref to it; this module only ever runs in that build. const host = hostRef.current as unknown as HTMLElement | null @@ -71,7 +76,7 @@ export const TerminalWebView = forwardRef( } let live try { - live = mountTerminalWebDocument(host, (message) => receiveRef.current?.(message)) + live = mountTerminalWebDocument(host, (message) => receiveRef.current?.(message), atMount) } catch (error) { // A start that throws is the document's own failure and the factory has already unwound // it, so there is no handle and no engine ran: no `error` notify is coming. It goes down @@ -101,7 +106,7 @@ export const TerminalWebView = forwardRef( } // Mounted once per generation: re-running this would throw away a live terminal and its // scrollback, and the controller's identity changes with every callback prop. - }, [generation]) + }, [atMount, generation]) const handleHostLayout = useCallback(() => { documentRef.current?.notifyViewport() diff --git a/mobile/src/terminal/document/document-host-root.test.ts b/mobile/src/terminal/document/document-host-root.test.ts index 1b98a5942cb..8a3e066bbe9 100644 --- a/mobile/src/terminal/document/document-host-root.test.ts +++ b/mobile/src/terminal/document/document-host-root.test.ts @@ -266,7 +266,15 @@ function startMeasuredDocument() { createUnicode11Addon: () => null, createWebglAddon: () => null }) - started.send({ type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false }) + // The app's init carries the frame it laid out. + started.send({ + type: 'init', + cols: 80, + rows: 24, + initialData: '', + preserveScroll: false, + frame: { width: 412, height: 600 } + }) const write = () => parsedWrites.forEach((listener) => listener()) return { started, posted, surface: surfaceOf(host), write } } diff --git a/mobile/src/terminal/document/document-host-seams.ts b/mobile/src/terminal/document/document-host-seams.ts index 8046447f370..1afaac6c1a1 100644 --- a/mobile/src/terminal/document/document-host-seams.ts +++ b/mobile/src/terminal/document/document-host-seams.ts @@ -45,6 +45,9 @@ export type TerminalDocumentErrorReporter = ( /** A frame from the host, as its transport delivers it: JSON text from a bridge, or the object. */ export type TerminalDocumentHostFrame = string | Record | undefined +/** How a document starts, as its view mounted. */ +export type TerminalDocumentStart = { textScale: number; shown: boolean } + /** * The ten host seams, kept apart from the state because the host sets them once when it builds * the scope, before the start sequence runs, and no module writes them afterwards. @@ -68,6 +71,12 @@ export type TerminalDocumentHostSeams = { installHostTransport: (receive: (frame: TerminalDocumentHostFrame) => void) => () => void /** `message-bridge`: whether the engine is here, which is what readiness is reported on. */ hasEngine: () => boolean + /** + * How the document starts, as its view mounted: the app's text scale, which the terminal built + * before ready lays out at, and whether it was shown. Only a shown document builds before ready; + * every terminal is a WebGL context, and a page or app holds about sixteen. + */ + start: () => TerminalDocumentStart /** Every fit, pan, scroll and overlay bound, and every client point mapped into the grid. */ viewportRect: () => TerminalDocumentViewportRect /** `fit-scale`: calls back when that box changes size or is shown again, handing back its removal. */ @@ -125,6 +134,12 @@ declare global { * quotes it. The document reaches it through a seam, so a page's mount holds its own instead. */ __engineErrors?: string[] + /** + * The WebView page writes these ahead of the document script: the text scale its view mounted + * at, and whether that view was shown then. + */ + __orcaTerminalTextScale?: unknown + __orcaTerminalShown?: unknown } const Terminal: new (options: Record) => TerminalDocumentTerminal } @@ -218,6 +233,14 @@ export function windowHasEngine() { return window.Terminal !== undefined } +export function windowStart(): TerminalDocumentStart { + const scale = window.__orcaTerminalTextScale + return { + textScale: typeof scale === 'number' && scale > 0 ? scale : 1, + shown: window.__orcaTerminalShown !== false + } +} + /** * The WebView's viewport: there the window is the terminal frame. On the page the window is the * whole page, taller than the frame by the header and dock, so the page answers with its host. diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index ee3bce4aa56..0095a3275ce 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -1,3 +1,4 @@ +import type { TerminalLaidOutCellBox } from '../terminal-cell-box' import { DEFAULT_TERMINAL_THEME } from '../terminal-webview-html/theme' import { createEngineTerminal, @@ -9,6 +10,7 @@ import { postToReactNativeWebView, windowCapturedEngineErrors, windowHasEngine, + windowStart, observeWindowViewport, windowViewportRect, type TerminalDocumentHost, @@ -94,6 +96,8 @@ export type TerminalDocumentState = { terminalFontFamily: string /** `terminal-init`: whether the first live chunk since init is still pending. */ firstDataPending: boolean + /** `laid-out-cell-box`: the box last reported or at ready, with the grid it was laid out at. */ + reportedCellBox: TerminalLaidOutCellBox | null /** `terminal-init`: whether the replayed snapshot was an alternate screen. */ activeAltScreenSnapshot: boolean /** `fit-scale`: the fit scale the document committed. */ @@ -156,6 +160,8 @@ export type TerminalDocumentState = { pendingTerm: TerminalDocumentTerminal | null /** `surface-swap`: the terminal the committed surface is showing. */ committedTerm: TerminalDocumentTerminal | null + /** `terminal-init`: the terminal built before ready, until the first init reuses it. */ + provisionalTerm: TerminalDocumentTerminal | null /** `surface-swap`: the surface the committed terminal is mounted on. */ committedSurface: HTMLElement | null /** `surface-swap`: the hidden replacement surface, until it commits. */ @@ -181,6 +187,8 @@ export type TerminalDocumentState = { removeWebglRecovery: (() => void) | null /** `fit-scale`: the generation of the retry loop; a bump abandons the one in flight. */ fitRetryToken: number + /** `host-message-router`: the terminal frame the app last sent with a grid, or null before one. */ + hostFrame: { width: number; height: number } | null /** `fit-scale`: the reason of a fit held while the host is hidden, or null when none is owed. */ fitPending: string | null /** `mouse-click-drag`: the mouse gesture in progress, or null. */ @@ -263,6 +271,7 @@ function createTerminalDocumentState(): TerminalDocumentState { currentTextScale: 1, terminalFontFamily: '', firstDataPending: false, + reportedCellBox: null, activeAltScreenSnapshot: false, currentScale: 1, userScale: 1, @@ -295,6 +304,7 @@ function createTerminalDocumentState(): TerminalDocumentState { surface: null, pendingTerm: null, committedTerm: null, + provisionalTerm: null, committedSurface: null, pendingSurface: null, scrollIndicator: null, @@ -308,6 +318,7 @@ function createTerminalDocumentState(): TerminalDocumentState { removeTapDispatch: null, removeWebglRecovery: null, fitRetryToken: 0, + hostFrame: null, fitPending: null, mouseGesture: null, touchDispatch: { @@ -346,6 +357,7 @@ function createTerminalDocumentHostSeams(): TerminalDocumentHostSeams { paintDocumentBackground: paintWindowDocumentBackground, installHostTransport: installWindowHostTransport, hasEngine: windowHasEngine, + start: windowStart, viewportRect: windowViewportRect, observeViewport: observeWindowViewport, root: null diff --git a/mobile/src/terminal/document/document-terminal-shape.ts b/mobile/src/terminal/document/document-terminal-shape.ts index 15cccfda35b..b5807efdf7f 100644 --- a/mobile/src/terminal/document/document-terminal-shape.ts +++ b/mobile/src/terminal/document/document-terminal-shape.ts @@ -102,6 +102,7 @@ export type TerminalDocumentTerminal = { onLineFeed?: (listener: () => void) => TerminalDocumentDisposable onScroll?: (listener: () => void) => TerminalDocumentDisposable onWriteParsed?: (listener: () => void) => TerminalDocumentDisposable + onRender?: (listener: () => void) => TerminalDocumentDisposable resize: (cols: number, rows: number) => void refresh: (start: number, end: number) => void dispose: () => void diff --git a/mobile/src/terminal/document/fit-scale.ts b/mobile/src/terminal/document/fit-scale.ts index 6fe03360988..8047b9f630c 100644 --- a/mobile/src/terminal/document/fit-scale.ts +++ b/mobile/src/terminal/document/fit-scale.ts @@ -12,9 +12,6 @@ import type { TerminalViewportChange } from './document-host-seams' import { scheduleDocumentFrame } from './document-frame-registry' import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' -/** The narrowest grid a fit or a text-scale change will fit to. */ -export const MIN_FIT_COLS = 20 - export function getCellHeight(scope: TerminalDocumentScope) { return getMeasuredCellHeight(scope) || 15 } diff --git a/mobile/src/terminal/document/host-message-router.ts b/mobile/src/terminal/document/host-message-router.ts index 8c63c715b17..0b155e1b70c 100644 --- a/mobile/src/terminal/document/host-message-router.ts +++ b/mobile/src/terminal/document/host-message-router.ts @@ -1,6 +1,5 @@ import type { TerminalDocumentScope } from './document-scope' -import { scheduleDocumentFrame } from './document-frame-registry' -import { applyFitScale, MIN_FIT_COLS } from './fit-scale' +import { applyFitScale } from './fit-scale' import { notify } from './host-notify' import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' import { emitModesIfChanged } from './mode-mirroring' @@ -12,7 +11,6 @@ import { resetEvictionCounter } from './selection-state-and-eviction' import { applyTerminalTheme } from './terminal-theme' import { init, resize, write } from './terminal-init' import { applyTextScale } from './text-scaling' -import { flog } from './viewport-transform' import { resetWriteQueue } from './write-queue' /** One message from the host. Every field is optional because the router reads them by type. */ @@ -27,83 +25,20 @@ export type TerminalHostMessage = { preserveScroll?: boolean oscLinks?: unknown data?: string - containerHeight?: number + frame?: { width: number; height: number } | null } -export function measureFitDimensions( - scope: TerminalDocumentScope, - containerHeightPx: unknown, - retriesLeft?: number -) { - if (typeof retriesLeft !== 'number') { - retriesLeft = 30 - } - // Why: init and measure are posted back-to-back from React, but - // init has an async rAF chain. A measure that runs synchronously - // after init can find term null, disposed, lacking element, or - // with cells size 0. Retry the whole gate for ~500ms. - const notReady = !scope.term || !scope.term.element - let cellWidth = 0 - let cellHeight = 0 - if (!notReady) { - const core = scope.term!._core - if (core && core._renderService && core._renderService.dimensions) { - cellWidth = core._renderService.dimensions.css.cell.width - cellHeight = core._renderService.dimensions.css.cell.height - } - } - if (notReady || cellWidth <= 0 || cellHeight <= 0) { - if (retriesLeft > 0) { - // Ruling 21: a retry that outlives its mount would answer the next mount's measure. - const gen = scope.terminalGeneration - scheduleDocumentFrame(scope, function () { - if (gen !== scope.terminalGeneration) { - return - } - measureFitDimensions(scope, containerHeightPx, retriesLeft - 1) - }) - return - } - flog(scope, 'measure-fail', { - notReady: notReady, - cellWidth: cellWidth, - cellHeight: cellHeight, - retriesLeft: retriesLeft - }) - notify(scope, { type: 'measure-result', cols: null, rows: null }) - return - } - const viewport = scope.viewportRect() - const vpWidth = viewport.width - // Why: prefer the container height passed from React Native over the - // viewport's. The RN layout system knows the exact pixel height of the - // terminal frame after the accessory/input bars are subtracted, whereas - // the viewport can overstate the visible area due to layout timing or - // safe-area insets. - const vpHeight = - typeof containerHeightPx === 'number' && containerHeightPx > 0 - ? containerHeightPx - : viewport.height - const cols = Math.floor(vpWidth / cellWidth) - if (cols < MIN_FIT_COLS) { - flog(scope, 'measure-skip-small-width', { - vpWidth: vpWidth, - cellWidth: cellWidth, - cols: cols - }) - notify(scope, { type: 'measure-result', cols: null, rows: null }) - return - } - // Why: the rows we report become the PTY's actual row count after the - // server fits to viewport, and xterm renders exactly that many lines - // anchored top-left of the WebView. Subtracting rows here would leave - // dead xterm-background space at the bottom of the container and make - // the last PTY rows visually appear above an "invisible line." Any - // safety margin between the prompt and the accessory bar must come - // from RN layout (terminalFrame's flex bounds), not from undersizing - // the PTY. - const rows = Math.max(8, Math.floor(vpHeight / cellHeight)) - notify(scope, { type: 'measure-result', cols: cols, rows: rows }) +/** The frame React Native laid out, sent with every grid; null when absent or without a size. */ +function hostFrameOf(msg: TerminalHostMessage) { + const frame = msg.frame + return frame && frame.width > 0 && frame.height > 0 + ? { width: frame.width, height: frame.height } + : null +} + +/** Keeps the frame a grid was fitted to, for a later text-scale change to fit. */ +function holdHostFrame(scope: TerminalDocumentScope, msg: TerminalHostMessage) { + scope.hostFrame = hostFrameOf(msg) ?? scope.hostFrame } export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage) { @@ -120,6 +55,7 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage if (msg.type === 'ping') { notify(scope, { type: 'pong', pingId: msg.id }) } else if (msg.type === 'init') { + holdHostFrame(scope, msg) init( scope, msg.cols!, @@ -144,8 +80,10 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage applyTextScale(scope, msg.fontScale) } } else if (msg.type === 'resize') { + holdHostFrame(scope, msg) resize(scope, msg.cols!, msg.rows!) } else if (msg.type === 'reflow') { + holdHostFrame(scope, msg) reflow(scope, msg.cols!, msg.rows!) } else if (msg.type === 'write') { write(scope, msg.data!) @@ -174,8 +112,6 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage notify(scope, { type: 'selection-evicted' }) cancelSelect(scope) } - } else if (msg.type === 'measure') { - measureFitDimensions(scope, msg.containerHeight) } else if (msg.type === 'reset-zoom') { applyFitScale(scope, 'reset-zoom-msg') } else if (msg.type === 'set-theme') { diff --git a/mobile/src/terminal/document/host-seams.test.ts b/mobile/src/terminal/document/host-seams.test.ts index 4ca8f18981e..17d709ddc42 100644 --- a/mobile/src/terminal/document/host-seams.test.ts +++ b/mobile/src/terminal/document/host-seams.test.ts @@ -205,24 +205,22 @@ describe('the document host seams, by default', () => { }) describe('the document host seams, once the page sets them', () => { - it('fits a measure with no container height to the host rather than the window', () => { - // The page's host is one element on a page that is taller and wider than it; the window is - // happy-dom's 1024x768, so a fit read off the window would answer 136x51. - const cell = { width: 7.5, height: 15 } - const terminal = Object.assign(terminalDouble(), { - _core: { _renderService: { dimensions: { css: { cell } } } } - }) - const posted: Record[] = [] + it('keeps the frame React Native laid out from each grid, not the viewport CSS rounded', () => { + // 1080 device px at a 2.75 pixel ratio: React Native lays the frame out at 392.73 and the + // document's viewport reads 393. A grid without a whole frame leaves the last one in place. + const frame = { width: 1080 / 2.75, height: 600 } const scope = startedScope({ - createTerminal: () => terminal, - postToHost: (message) => posted.push(message), - viewportRect: () => ({ left: 0, top: 82, width: 390, height: 600 }) + createTerminal: () => terminalDouble(), + viewportRect: () => ({ left: 0, top: 0, width: 393, height: 600 }) }) handleMsg(scope, { type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false }) - handleMsg(scope, { type: 'measure' }) - expect(posted.filter((message) => message.type === 'measure-result')).toEqual([ - { type: 'measure-result', cols: 52, rows: 40 } - ]) + expect(scope.hostFrame).toBeNull() + handleMsg(scope, { type: 'resize', cols: 50, rows: 40, frame: { width: 390, height: 0 } }) + expect(scope.hostFrame).toBeNull() + handleMsg(scope, { type: 'resize', cols: 50, rows: 40, frame }) + expect(scope.hostFrame).toEqual(frame) + handleMsg(scope, { type: 'reflow', cols: 50, rows: 40 }) + expect(scope.hostFrame).toEqual(frame) }) it('routes every notify to the field and nothing to the bridge', () => { @@ -232,7 +230,7 @@ describe('the document host seams, once the page sets them', () => { const scope = startedScope({ postToHost: (message) => posted.push(message) }) // The sequence's own `web-ready` is the document reporting itself started; what this case reads // is what the two notify paths send afterwards. - expect(posted).toEqual([{ type: 'web-ready' }]) + expect(posted).toEqual([expect.objectContaining({ type: 'web-ready' })]) posted.length = 0 notify(scope, { type: 'pong', pingId: 7 }) flog(scope, 'probe', { n: 1 }) diff --git a/mobile/src/terminal/document/laid-out-cell-box.test.ts b/mobile/src/terminal/document/laid-out-cell-box.test.ts new file mode 100644 index 00000000000..5a8ed7453a0 --- /dev/null +++ b/mobile/src/terminal/document/laid-out-cell-box.test.ts @@ -0,0 +1,343 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { laidOutCellBox, reportLaidOutCellBox } from './laid-out-cell-box' +import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope' +import { startTerminalDocument, stopTerminalDocument } from './create-terminal-document' +import type { TerminalDocumentHost } from './document-host-seams' +import { fontPxForScale } from './text-scaling' +import { terminalDocumentDouble } from './document-terminal-double.test-support' +import { handleMsg } from './host-message-router' +import { TERMINAL_DOCUMENT_MARKUP } from '../terminal-webview-html/document-markup' + +afterEach(() => { + vi.unstubAllGlobals() + document.body.innerHTML = '' +}) + +describe('laidOutCellBox', () => { + function scopeWithCell( + cell: { width: number; height: number }, + fontSize: number, + posted: Record[] = [] + ) { + const scope = createTerminalDocumentScope({ postToHost: (message) => posted.push(message) }) + scope.currentTextScale = 1.25 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader touches only cols, rows, options.fontSize and _core's render dimensions. + scope.term = { + cols: 55, + rows: 47, + options: { fontSize }, + _core: { _renderService: { dimensions: { css: { cell } } } } + } as unknown as typeof scope.term + return scope + } + + it('reads the box xterm laid out at the current text scale, with its grid', () => { + const scope = scopeWithCell({ width: 29 / 3, height: 21 }, fontPxForScale(1.25)) + expect(laidOutCellBox(scope)).toEqual({ + cellBox: { fontScale: 1.25, cellWidth: 29 / 3, cellHeight: 21 }, + cols: 55, + rows: 47 + }) + }) + + it('tells the host each new box once, and refits only a box that changed at a kept grid', () => { + const posted: Record[] = [] + const cell = { width: 29 / 3, height: 21 } + const scope = scopeWithCell(cell, fontPxForScale(1.25), posted) + reportLaidOutCellBox(scope) + reportLaidOutCellBox(scope) + // A new grid with the same box tells the host nothing; the document holds that grid. + Object.assign(scope.term!, { cols: 50 }) + reportLaidOutCellBox(scope) + cell.width = 9.75 + reportLaidOutCellBox(scope) + expect(posted).toEqual([ + { + type: 'cell-box', + cellBox: { fontScale: 1.25, cellWidth: 29 / 3, cellHeight: 21 }, + refit: false + }, + { + type: 'cell-box', + cellBox: { fontScale: 1.25, cellWidth: 9.75, cellHeight: 21 }, + refit: true + } + ]) + }) +}) + +describe('a started document', () => { + /** A document whose engine builds grids with a cell that follows the font, and every notify. */ + function started(host: TerminalDocumentHost = {}) { + document.body.innerHTML = TERMINAL_DOCUMENT_MARKUP + const cell = { width: 23 / 3, height: 15 } + const renderListeners: (() => void)[] = [] + const built: { options: Record }[] = [] + const createTerminal = (options: Record) => { + const terminal = terminalDocumentDouble().terminal + const grid = Object.assign(terminal, { + cols: Number(options.cols), + rows: Number(options.rows), + options: { ...terminal.options, fontSize: Number(options.fontSize) }, + _core: { + _renderService: { + get dimensions() { + const k = grid.options.fontSize / 13 + return { css: { cell: { width: cell.width * k, height: cell.height * k } } } + } + } + }, + onRender: (listener: () => void) => { + renderListeners.push(listener) + return { dispose() {} } + } + }) + grid.resize = (cols: number, rows: number) => { + grid.cols = cols + grid.rows = rows + } + built.push({ options }) + return grid + } + const posted: Record[] = [] + const scope = createTerminalDocumentScope({ + installHostTransport: () => () => {}, + hasEngine: () => true, + createTerminal, + postToHost: (message) => posted.push(message), + ...host + }) + startTerminalDocument(scope) + const render = () => renderListeners.forEach((listener) => listener()) + const init = () => + handleMsg(scope, { type: 'init', cols: 55, rows: 47, initialData: '', preserveScroll: false }) + return { scope, cell, posted, render, built, init } + } + + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())) + async function untilReady(posted: Record[]) { + for (let frame = 0; frame < 30 && !posted.some((m) => m.type === 'ready'); frame++) { + await nextFrame() + } + } + const boxes = (posted: Record[]) => + posted.filter((m) => m.type === 'cell-box').map((m) => m.cellBox) + const refits = (posted: Record[]) => + posted.filter((m) => m.type === 'cell-box').map((m) => m.refit) + const reflowTo = (scope: TerminalDocumentScope, cols: number) => + handleMsg(scope, { type: 'reflow', cols, rows: 47 }) + + it('takes the frame the app laid out from init, resize and reflow', () => { + const { scope } = started() + try { + handleMsg(scope, { + type: 'init', + cols: 55, + rows: 47, + initialData: '', + preserveScroll: false, + frame: { width: 427.5, height: 710 } + }) + expect(scope.hostFrame).toEqual({ width: 427.5, height: 710 }) + } finally { + stopTerminalDocument(scope) + } + }) + + it('reports ready with the box its own terminal laid out, at the app text scale', () => { + const { scope, posted, built } = started({ start: () => ({ textScale: 1.25, shown: true }) }) + try { + expect(built).toHaveLength(1) + expect(built[0].options.fontSize).toBe(fontPxForScale(1.25)) + expect(posted[0]).toEqual({ + type: 'web-ready', + cellBox: { fontScale: 1.25, cellWidth: (23 / 3) * (16 / 13), cellHeight: 15 * (16 / 13) } + }) + } finally { + stopTerminalDocument(scope) + } + }) + + it('leaves no live terminal when opening the one it builds before ready throws', () => { + const double = terminalDocumentDouble() + double.terminal.open = () => { + throw new Error('no surface') + } + const { scope, posted } = started({ createTerminal: () => double.terminal }) + try { + expect(scope.term).toBeNull() + expect(double.disposals()).toBe(1) + expect(posted[0]).toEqual({ type: 'web-ready', cellBox: null }) + } finally { + stopTerminalDocument(scope) + } + }) + + it('builds nothing before ready for a view hidden when it mounted, and reports no box', () => { + const { scope, posted, built } = started({ start: () => ({ textScale: 1, shown: false }) }) + try { + expect(built).toHaveLength(0) + expect(posted[0]).toEqual({ type: 'web-ready', cellBox: null }) + } finally { + stopTerminalDocument(scope) + } + }) + + it('reuses that terminal for the first init, so an open builds one xterm', async () => { + const { scope, posted, built, init } = started() + try { + const prepared = scope.term + init() + await untilReady(posted) + expect(built).toHaveLength(1) + expect(scope.term).toBe(prepared) + expect(scope.term?.cols).toBe(55) + expect(scope.term?.rows).toBe(47) + } finally { + stopTerminalDocument(scope) + } + }) + + it('keeps the terminal built before ready out of sight until the first init has replayed', async () => { + const { scope, posted, init } = started() + try { + // Hidden, not removed from layout: the box is measured and reported while it is out of sight. + expect(scope.surface?.style.visibility).toBe('hidden') + expect(scope.surface?.style.display).toBe('') + expect(posted[0]).toEqual({ + type: 'web-ready', + cellBox: { fontScale: 1, cellWidth: 23 / 3, cellHeight: 15 } + }) + init() + expect(scope.surface?.style.visibility).toBe('hidden') + await untilReady(posted) + expect(scope.surface?.style.visibility).toBe('') + // The box is the ready's own at the init's grid, which the app already holds. + expect(posted.filter((message) => message.type === 'cell-box')).toEqual([]) + } finally { + stopTerminalDocument(scope) + } + }) + + it('reports a paused renderer at ready, before ready itself', async () => { + const { scope, cell, posted, init } = started() + try { + // The DOM renderer's box at the init's grid, which the paused renderer has not drawn yet. + cell.width = 7.8 + init() + await untilReady(posted) + const types = posted.map((m) => m.type) + expect(types.indexOf('cell-box')).toBeGreaterThan(-1) + expect(types.indexOf('cell-box')).toBeLessThan(types.indexOf('ready')) + expect(boxes(posted)).toEqual([{ fontScale: 1, cellWidth: 7.8, cellHeight: 15 }]) + } finally { + stopTerminalDocument(scope) + } + }) + + it('reports a text-size change on the render after it, not when the font is set', async () => { + // xterm updates the render service's cell box synchronously when fontSize is set + // (CharSizeService.measure → RenderService.handleCharSizeChanged), and onRender fires after the + // rows it redrew, so the next render reads the new box at the new scale. + const { scope, posted, render, init } = started() + try { + init() + await untilReady(posted) + const reported = boxes(posted).length + handleMsg(scope, { type: 'set-font-scale', fontScale: 1.25 }) + expect(boxes(posted)).toHaveLength(reported) + render() + expect(boxes(posted).at(-1)).toEqual({ + fontScale: 1.25, + cellWidth: (23 / 3) * (16 / 13), + cellHeight: 15 * (16 / 13) + }) + } finally { + stopTerminalDocument(scope) + } + }) + + it('reports a renderer swap after context loss on the next render', async () => { + const { scope, cell, posted, render, init } = started() + try { + init() + await untilReady(posted) + cell.width = 7.8 + render() + expect(boxes(posted).at(-1)).toEqual({ fontScale: 1, cellWidth: 7.8, cellHeight: 15 }) + } finally { + stopTerminalDocument(scope) + } + }) + + it("refits a DOM first init once, and not again on the refit's own report", async () => { + const { scope, cell, posted, render, init } = started() + try { + // The terminal built before ready laid its box out at 80x24; the DOM renderer's box follows cols. + cell.width = 7.8 + init() + await untilReady(posted) + expect(refits(posted)).toEqual([true]) + cell.width = 7.9 + reflowTo(scope, 54) + render() + expect(refits(posted)).toEqual([true, false]) + } finally { + stopTerminalDocument(scope) + } + }) + + it('never refits a box that came with a new grid, so the DOM renderer cannot loop', async () => { + const { scope, cell, posted, render, init } = started() + try { + init() + await untilReady(posted) + for (const [cols, width] of [ + [50, 7.9], + [55, 7.8], + [54, 8] + ]) { + cell.width = width + reflowTo(scope, cols) + render() + } + expect(refits(posted)).toEqual([false, false, false]) + } finally { + stopTerminalDocument(scope) + } + }) + + it('refits a renderer swap at a grid applied in place', async () => { + const { scope, cell, posted, render, init } = started() + try { + init() + await untilReady(posted) + // A width change applied in place: the WebGL box is unchanged, so nothing is told. + reflowTo(scope, 50) + render() + expect(refits(posted)).toEqual([]) + // WebGL context lost: the DOM fallback lays out a new box at the same grid. + cell.width = 7.8 + render() + expect(refits(posted)).toEqual([true]) + } finally { + stopTerminalDocument(scope) + } + }) + + it('refits a re-init at the same grid with a different renderer', async () => { + const { scope, cell, posted, init } = started() + try { + init() + await untilReady(posted) + posted.length = 0 + cell.width = 7.8 + init() + await untilReady(posted) + expect(refits(posted)).toEqual([true]) + } finally { + stopTerminalDocument(scope) + } + }) +}) diff --git a/mobile/src/terminal/document/laid-out-cell-box.ts b/mobile/src/terminal/document/laid-out-cell-box.ts new file mode 100644 index 00000000000..3c6c45fa723 --- /dev/null +++ b/mobile/src/terminal/document/laid-out-cell-box.ts @@ -0,0 +1,38 @@ +import { sameCellBox, type TerminalLaidOutCellBox } from '../terminal-cell-box' +import type { TerminalDocumentScope } from './document-scope' +import { notify } from './host-notify' + +/** The box xterm laid out at the current text scale, with its grid; null before one. */ +export function laidOutCellBox(scope: TerminalDocumentScope): TerminalLaidOutCellBox | null { + const term = scope.term + const dimensions = term && term._core && term._core._renderService?.dimensions + if (!term || !dimensions) { + return null + } + const { width, height } = dimensions.css.cell + if (!(width > 0 && height > 0)) { + return null + } + const cellBox = { fontScale: scope.currentTextScale, cellWidth: width, cellHeight: height } + return { cellBox, cols: term.cols, rows: term.rows } +} + +/** + * Tells the host each box xterm lays out that differs from the last: after init, a renderer swap + * on context loss, a text-size or DPR change, or a resize (the DOM renderer's width follows cols). + * `refit` is a box that changed while the grid did not, which only a renderer or pixel-ratio + * change does; one that arrives with a new grid is that grid's own, so refitting it would loop. + */ +export function reportLaidOutCellBox(scope: TerminalDocumentScope) { + const laidOut = laidOutCellBox(scope) + if (!laidOut) { + return + } + const last = scope.reportedCellBox + scope.reportedCellBox = laidOut + if (last && sameCellBox(last.cellBox, laidOut.cellBox)) { + return + } + const refit = last !== null && last.cols === laidOut.cols && last.rows === laidOut.rows + notify(scope, { type: 'cell-box', cellBox: laidOut.cellBox, refit }) +} diff --git a/mobile/src/terminal/document/message-bridge.ts b/mobile/src/terminal/document/message-bridge.ts index b4e21da07b7..f86bec8b743 100644 --- a/mobile/src/terminal/document/message-bridge.ts +++ b/mobile/src/terminal/document/message-bridge.ts @@ -1,5 +1,7 @@ import { handleMsg, type TerminalHostMessage } from './host-message-router' import { notify, reportEngineError, type TerminalEngineError } from './host-notify' +import { laidOutCellBox } from './laid-out-cell-box' +import { prepareTerminal } from './terminal-init' import type { TerminalDocumentScope } from './document-scope' import type { TerminalDocumentHostFrame } from './document-host-seams' @@ -45,7 +47,13 @@ export function startMessageBridge(scope: TerminalDocumentScope) { handleIncomingMessage(scope, frame) ) if (scope.hasEngine()) { - notify(scope, { type: 'web-ready' }) + // Why: ready carries the cell box xterm itself laid out, so the host sizes the first subscribe. + if (scope.start().shown) { + prepareTerminal(scope) + } + // Why: the first init's report is checked against the box the terminal built before ready laid out. + scope.reportedCellBox = laidOutCellBox(scope) + notify(scope, { type: 'web-ready', cellBox: scope.reportedCellBox?.cellBox ?? null }) } else { reportEngineError(scope, 'terminal engine missing', 'xterm failed to load', true) } diff --git a/mobile/src/terminal/document/native-document-bundle.test.ts b/mobile/src/terminal/document/native-document-bundle.test.ts index 15eb000ee1f..4389c3bb105 100644 --- a/mobile/src/terminal/document/native-document-bundle.test.ts +++ b/mobile/src/terminal/document/native-document-bundle.test.ts @@ -311,7 +311,10 @@ describe('the bundled native document', () => { // that ships rather than about a bundle this case built for itself. const { script, inputs } = await terminalDocumentBundle() expect(inputs.filter((input) => input.includes('node_modules'))).toEqual([]) - expect(inputs).toHaveLength(47) + // 50: `laid-out-cell-box` reads the cell box xterm laid out, for web-ready and each render; + // `terminal-grid-fit` is the one fit of that box and `terminal-cell-box` its one comparison, + // both shared with the app. + expect(inputs).toHaveLength(50) expect(script).not.toContain('__commonJS') // `__esm` wrappers are esbuild's answer to a cycle, and a cycle would make a module's top level // run at first import rather than where the bundle places it. diff --git a/mobile/src/terminal/document/term-observers.ts b/mobile/src/terminal/document/term-observers.ts index 80f3ec597af..e35a85acc4f 100644 --- a/mobile/src/terminal/document/term-observers.ts +++ b/mobile/src/terminal/document/term-observers.ts @@ -4,6 +4,7 @@ import type { TerminalDocumentScope } from './document-scope' import { logFeedAndEvict } from './selection-state-and-eviction' import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' import { emitModesIfChanged } from './mode-mirroring' +import { reportLaidOutCellBox } from './laid-out-cell-box' export function attachTermObservers(scope: TerminalDocumentScope) { if (!scope.term) { @@ -36,6 +37,16 @@ export function attachTermObservers(scope: TerminalDocumentScope) { ) } } catch {} + // Why: onDimensionsChange skips a renderer swap and a DPR change; every one of them re-renders. + try { + if (scope.term.onRender) { + scope.termObserverDisposables.push( + scope.term.onRender(function () { + reportLaidOutCellBox(scope) + }) + ) + } + } catch {} // Initial emit once buffer settles. afterWritesDrained(scope, function () { emitModesIfChanged(scope) diff --git a/mobile/src/terminal/document/terminal-init.ts b/mobile/src/terminal/document/terminal-init.ts index f63e2b5f508..04e90b4578e 100644 --- a/mobile/src/terminal/document/terminal-init.ts +++ b/mobile/src/terminal/document/terminal-init.ts @@ -2,8 +2,9 @@ import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' import { MOBILE_TERMINAL_CARET_OPTIONS } from '../terminal-webview-html/theme' import { ESC } from './escape-introducers' import { notify } from './host-notify' +import { reportLaidOutCellBox } from './laid-out-cell-box' import { fontPxForScale } from './text-scaling' -import type { TerminalDocumentScope } from './document-scope' +import type { TerminalDocumentScope, TerminalDocumentTerminal } from './document-scope' import { scheduleDocumentFrame } from './document-frame-registry' import { applyFitScale } from './fit-scale' import { @@ -21,6 +22,63 @@ import { applyTerminalTheme } from './terminal-theme' import { attachWebglAddon, cancelWebglContextRecovery } from './webgl-recovery' import { afterWritesDrained, enqueueWrite, pumpWrites, resetWriteQueue } from './write-queue' +/** Builds and opens a terminal on the current surface, with the renderer and addons attached. */ +function openTerminal(scope: TerminalDocumentScope, cols: number, rows: number) { + const term = scope.createTerminal({ + cols: cols, + rows: rows, + theme: scope.terminalTheme, + minimumContrastRatio: scope.terminalMinimumContrastRatio, + fontFamily: scope.terminalFontFamily, + fontSize: fontPxForScale(scope.currentTextScale), + fontWeight: '300', + fontWeightBold: '500', + scrollback: 5000, + // Why: xterm suppresses parser-generated query replies when disableStdin + // is true. Native accepts only validated reply grammars from onData. + disableStdin: false, + cursorBlink: MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink, + cursorStyle: MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle, + // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. + showCursorImmediately: MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately, + // A full inactive cell remains visible under the terminal's phone-fit scale. + cursorInactiveStyle: MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle, + convertEol: false, + allowProposedApi: true + }) + scope.term = term + term.open(scope.surface!) + attachWebglAddon(scope, true) + try { + const unicodeAddon = scope.createUnicode11Addon() + if (unicodeAddon) { + term.loadAddon(unicodeAddon) + term.unicode.activeVersion = '11' + } + } catch {} + return term +} + +/** + * The document's terminal, built before it reports ready so ready can carry the cell box xterm + * lays out at the app's text scale. Any size will do: the first init resizes and reuses it. + */ +export function prepareTerminal(scope: TerminalDocumentScope) { + // Why: laid out for the measure, but not shown — an empty 80x24 grid and caret until init. + scope.surface!.style.visibility = 'hidden' + try { + scope.committedTerm = openTerminal(scope, 80, 24) + scope.provisionalTerm = scope.committedTerm + } catch { + // Why: init builds again and reports the failure through the path every init failure takes. + scope.surface!.style.visibility = '' + try { + scope.term?.dispose() + } catch {} + scope.term = null + } +} + export function init( scope: TerminalDocumentScope, cols: number, @@ -44,11 +102,15 @@ export function init( const scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1 scope.terminalGeneration++ const gen = scope.terminalGeneration + const reused = scope.provisionalTerm + scope.provisionalTerm = null // Why: snapshot replay can contain old queries whose replies must never // re-enter the live PTY. Each replacement terminal earns authority anew. resetTerminalDataReplyAuthority(scope) - cancelWebglContextRecovery(scope) - scope.webglAddon = null + if (!reused) { + cancelWebglContextRecovery(scope) + scope.webglAddon = null + } scope.ready = false resetWriteQueue(scope) scope.statusDotPendingSelector = false @@ -77,44 +139,27 @@ export function init( scope.initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : [] scope.initialOscLinkRowOffset = 0 scope.initialOscLinkEvictionReady = false - const surfaceSwap = beginTerminalSurfaceSwap(scope) - // oxlint-disable-next-line no-unused-vars -- the document declares it here; removing it is a different program - const nextSurface = surfaceSwap.nextSurface + // Why: the terminal built before ready is on the committed surface already; only a + // replacement needs a hidden surface to replay into. + const surfaceSwap = reused ? null : beginTerminalSurfaceSwap(scope) applyTerminalTheme(scope, nextTheme) - scope.term = scope.createTerminal({ - cols: cols || 80, - rows: rows || 24, - theme: scope.terminalTheme, - minimumContrastRatio: scope.terminalMinimumContrastRatio, - fontFamily: scope.terminalFontFamily, - fontSize: fontPxForScale(scope.currentTextScale), - fontWeight: '300', - fontWeightBold: '500', - scrollback: 5000, - // Why: xterm suppresses parser-generated query replies when disableStdin - // is true. Native accepts only validated reply grammars from onData. - disableStdin: false, - cursorBlink: MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink, - cursorStyle: MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle, - // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. - showCursorImmediately: MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately, - // A full inactive cell remains visible under the terminal's phone-fit scale. - cursorInactiveStyle: MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle, - convertEol: false, - allowProposedApi: true - }) - const nextTerm = scope.term - scope.pendingTerm = nextTerm - scope.term.open(scope.surface!) - attachWebglAddon(scope, true) - try { - const unicodeAddon = scope.createUnicode11Addon() - if (unicodeAddon) { - scope.term.loadAddon(unicodeAddon) - scope.term.unicode.activeVersion = '11' + let nextTerm: TerminalDocumentTerminal + if (reused) { + nextTerm = reused + scope.term = reused + reused.reset() + reused.options.fontSize = fontPxForScale(scope.currentTextScale) + reused.resize(cols || 80, rows || 24) + // Why: its box was laid out at 80x24; the first report at this grid must read as the box + // changing at a kept grid, which the DOM renderer's cols-dependent box then refits once. + if (scope.reportedCellBox) { + scope.reportedCellBox = { ...scope.reportedCellBox, cols: reused.cols, rows: reused.rows } } - } catch {} + } else { + nextTerm = openTerminal(scope, cols || 80, rows || 24) + scope.pendingTerm = nextTerm + } if (typeof replayData === 'string' && replayData.length > 0) { // Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output. enqueueWrite(scope, ESC + '[0m' + replayData) @@ -124,7 +169,7 @@ export function init( resetEvictionCounter(scope) cancelSelect(scope) attachTermObservers(scope) - attachTerminalQueryReplyBridge(scope, scope.term, gen) + attachTerminalQueryReplyBridge(scope, nextTerm, gen) scheduleDocumentFrame(scope, function () { if (gen !== scope.terminalGeneration) { @@ -136,7 +181,12 @@ export function init( if (gen !== scope.terminalGeneration) { return } - commitTerminalSurfaceSwap(scope, surfaceSwap, nextTerm) + if (surfaceSwap) { + commitTerminalSurfaceSwap(scope, surfaceSwap, nextTerm) + } else { + // The terminal built before ready, shown now that it holds the replay. + scope.surface!.style.visibility = '' + } // Why: restore the reader's place after the rewrapped buffer replays. // Replay lands at bottom, so only act when they were scrolled up (rows>0). if (scrollAnchorRows > 0 && scope.term && scope.term.buffer && scope.term.buffer.active) { @@ -150,6 +200,8 @@ export function init( scope.initialOscLinkRowOffset = 0 scope.initialOscLinkEvictionReady = true applyFitScale(scope, 'init-replay') + // Why: a paused (hidden) render service has not rendered yet, so ready reports it too. + reportLaidOutCellBox(scope) notify(scope, { type: 'ready', cols: cols, rows: rows }) }) }) @@ -207,4 +259,5 @@ export function stopTerminalInit(scope: TerminalDocumentScope) { } scope.term = null scope.committedTerm = null + scope.provisionalTerm = null } diff --git a/mobile/src/terminal/document/text-scaling.ts b/mobile/src/terminal/document/text-scaling.ts index eb6cff4a9f4..88d7b5dd762 100644 --- a/mobile/src/terminal/document/text-scaling.ts +++ b/mobile/src/terminal/document/text-scaling.ts @@ -2,7 +2,8 @@ import { elementInRoot } from './document-host-seams' import { TERMINAL_TEXT_SCALES } from '../terminal-text-scales' import type { TerminalDocumentScope } from './document-scope' import { scheduleDocumentFrame } from './document-frame-registry' -import { applyFitScale, getCellHeight, MIN_FIT_COLS } from './fit-scale' +import { applyFitScale, getCellHeight } from './fit-scale' +import { fitDimensionsFromCell } from '../terminal-grid-fit' import { getCellWidth } from './viewport-transform' // Why: init() flips ready false on every re-init (live width reflow included) @@ -77,21 +78,31 @@ export function applyTextScale(scope: TerminalDocumentScope, scale: number) { } const cellW = getCellWidth(scope) const cellH = getCellHeight(scope) + // Why: fit the frame React Native laid out, by the same formula; init and measure give it. Before + // either, the pre-ready terminal stays hidden until the first init, which applies the font and resizes. + const frame = scope.hostFrame + if (!frame) { + return + } if (cellW > 0 && cellH > 0) { - const cols = Math.floor(scope.viewportRect().width / cellW) - if (cols < MIN_FIT_COLS) { + const fit = fitDimensionsFromCell( + { cellWidth: cellW, cellHeight: cellH }, + frame.width, + frame.height + ) + if (!fit) { // Why: too narrow to resize the grid, but the fit still tracks the new cell size; hidden hosts hold it. applyFitScale(scope, 'text-scale') return } - const rows = Math.max(8, Math.floor(scope.viewportRect().height / cellH)) - scope.term.resize(cols, rows) + scope.term.resize(fit.cols, fit.rows) } applyFitScale(scope, 'text-scale') }) } export function startTextScaling(scope: TerminalDocumentScope) { + scope.currentTextScale = scope.start().textScale scope.scrollIndicator = elementInRoot(scope.root, 'scroll-indicator') scope.scrollThumb = elementInRoot(scope.root, 'scroll-thumb') scope.terminalFontFamily = diff --git a/mobile/src/terminal/terminal-cell-box-census.test.ts b/mobile/src/terminal/terminal-cell-box-census.test.ts new file mode 100644 index 00000000000..ea0e9ece12c --- /dev/null +++ b/mobile/src/terminal/terminal-cell-box-census.test.ts @@ -0,0 +1,62 @@ +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-cell-box-refit.test.tsx b/mobile/src/terminal/terminal-cell-box-refit.test.tsx new file mode 100644 index 00000000000..0bf8e774bc9 --- /dev/null +++ b/mobile/src/terminal/terminal-cell-box-refit.test.tsx @@ -0,0 +1,128 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalWebViewHandle } from './terminal-webview-contract' +import { useTerminalViewportRefit, type TerminalViewportDims } from './terminal-viewport-refit' + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: () => ({ remove() {} }) }, + Platform: { OS: 'android' }, + useWindowDimensions: () => ({ width: 427, height: 900 }) +})) + +const HANDLE = 'term-1' + +/** The session's refit over one open terminal whose document reported a `cellWidth` box. */ +function refitHarness(cellWidth: number) { + const terminal: TerminalWebViewHandle = { + prepareForForegroundRecovery: vi.fn(), + write: vi.fn(), + init: vi.fn(), + resize: vi.fn(), + reflow: vi.fn(), + clear: vi.fn(), + // The reported box, 47 rows high in the 710 px frame. + fitDimensions: vi.fn((frame: { width: number }) => ({ + cols: Math.floor(frame.width / cellWidth), + rows: 47 + })), + resetZoom: vi.fn(), + cancelSelect: vi.fn(), + doSelectAll: vi.fn(), + awaitReady: vi.fn(async () => {}) + } + const viewportRef: { current: TerminalViewportDims | null } = { current: { cols: 55, rows: 47 } } + const subscribeToTerminal = vi.fn() + const unsubscribeTerminal = vi.fn() + const terminalFrameRef: { current: { width: number; height: number } | null } = { + current: { width: 427, height: 710 } + } + let refit: ReturnType | undefined + function Probe() { + refit = useTerminalViewportRefit({ + activeHandleRef: { current: HANDLE }, + terminalRefs: { current: new Map([[HANDLE, terminal]]) }, + terminalFrameRef, + viewportRef, + viewportMeasuredRef: { current: true }, + nativeChatCoveredRef: { current: false }, + clientRef: { current: null }, + deviceTokenRef: { current: null }, + initializedHandlesRef: { current: new Set([HANDLE]) }, + connState: 'connected', + tabStripVisible: false, + textScale: 1, + unsubscribeTerminal, + subscribeToTerminal + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + return { + terminal, + viewportRef, + subscribeToTerminal, + report: (handle: string) => act(() => refit!.notifyTerminalCellBoxChange(handle)), + // The session's onLayout: the one frame store, then the width notify. + layOut: (width: number) => + act(() => { + terminalFrameRef.current = { width, height: 710 } + refit!.notifyTerminalFrameWidth() + }) + } +} + +let renderer: ReactTestRenderer | undefined +beforeEach(() => { + vi.useFakeTimers() +}) +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.useRealTimers() +}) + +describe('a new cell box for the open terminal', () => { + it('refits the PTY to the grid the new box fits, as after a renderer swap', async () => { + const harness = refitHarness(7.8) + harness.report(HANDLE) + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + expect(harness.terminal.fitDimensions).toHaveBeenLastCalledWith({ width: 427, height: 710 }) + expect(harness.viewportRef.current).toEqual({ cols: 54, rows: 47 }) + expect(harness.subscribeToTerminal).toHaveBeenCalledWith(HANDLE) + }) + + it('leaves a terminal that is not on screen alone', async () => { + const harness = refitHarness(7.8) + harness.report('term-2') + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + expect(harness.subscribeToTerminal).not.toHaveBeenCalled() + expect(harness.viewportRef.current).toEqual({ cols: 55, rows: 47 }) + }) + + it('leaves the PTY alone when a new frame width holds the same grid, as sub-pixel jitter does', async () => { + const harness = refitHarness(23 / 3) + harness.layOut(427.3) + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + expect(harness.subscribeToTerminal).not.toHaveBeenCalled() + expect(harness.viewportRef.current).toEqual({ cols: 55, rows: 47 }) + }) + + it('refits when a new frame width holds a different grid', async () => { + const harness = refitHarness(23 / 3) + harness.layOut(420) + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + expect(harness.terminal.fitDimensions).toHaveBeenLastCalledWith({ width: 420, height: 710 }) + expect(harness.viewportRef.current).toEqual({ cols: 54, rows: 47 }) + }) +}) diff --git a/mobile/src/terminal/terminal-cell-box.test.ts b/mobile/src/terminal/terminal-cell-box.test.ts new file mode 100644 index 00000000000..9ddec3bcc07 --- /dev/null +++ b/mobile/src/terminal/terminal-cell-box.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { readTerminalCellBox } from './terminal-cell-box' + +// 23 device px at DPR 3: the WebGL renderer's 13px cell on the emulator. +const CELL_1X = { fontScale: 1, cellWidth: 23 / 3, cellHeight: 15 } + +describe('readTerminalCellBox', () => { + it('reads nothing from a notify without a cell box', () => { + expect(readTerminalCellBox({ type: 'web-ready' })).toBeNull() + }) + + it('reads a well-formed box and rejects a malformed one', () => { + expect(readTerminalCellBox({ cellBox: CELL_1X })).toEqual(CELL_1X) + expect(readTerminalCellBox({ cellBox: { ...CELL_1X, cellWidth: 0 } })).toBeNull() + expect(readTerminalCellBox({ cellBox: [CELL_1X] })).toBeNull() + }) +}) diff --git a/mobile/src/terminal/terminal-cell-box.ts b/mobile/src/terminal/terminal-cell-box.ts new file mode 100644 index 00000000000..84315049991 --- /dev/null +++ b/mobile/src/terminal/terminal-cell-box.ts @@ -0,0 +1,36 @@ +/** + * The cell box xterm laid out in one document, so the first subscribe after ready carries the phone's dims. + * + * The document builds its terminal before it reports ready, puts that box in `web-ready`, and + * reports it again whenever xterm lays out a different one. + */ +export type TerminalCellBox = { fontScale: number; cellWidth: number; cellHeight: number } + +/** A box xterm laid out, with the grid it was laid out at: the document's own record. */ +export type TerminalLaidOutCellBox = { cellBox: TerminalCellBox; cols: number; rows: number } + +function positive(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +/** The notify's `cellBox` when well formed; null when absent or malformed. */ +export function readTerminalCellBox(msg: Record): TerminalCellBox | null { + const box = msg.cellBox + if ( + typeof box !== 'object' || + box === null || + !('fontScale' in box && 'cellWidth' in box && 'cellHeight' in box) + ) { + return null + } + const fontScale = positive(box.fontScale) + const cellWidth = positive(box.cellWidth) + const cellHeight = positive(box.cellHeight) + return fontScale !== null && cellWidth !== null && cellHeight !== null + ? { fontScale, cellWidth, cellHeight } + : null +} + +export function sameCellBox(a: TerminalCellBox, b: TerminalCellBox): boolean { + return a.fontScale === b.fontScale && a.cellWidth === b.cellWidth && a.cellHeight === b.cellHeight +} diff --git a/mobile/src/terminal/terminal-grid-fit.test.ts b/mobile/src/terminal/terminal-grid-fit.test.ts new file mode 100644 index 00000000000..77c0be8cdcb --- /dev/null +++ b/mobile/src/terminal/terminal-grid-fit.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { fitDimensionsFromCell } from './terminal-grid-fit' + +// 23 device px at DPR 3: the WebGL renderer's 13px cell on the emulator. +const CELL_1X = { cellWidth: 23 / 3, cellHeight: 15 } + +describe('fitDimensionsFromCell', () => { + it('fits the frame React Native laid out', () => { + // floor(427 / 7.667) = 55, floor(710 / 15) = 47 + expect(fitDimensionsFromCell(CELL_1X, 427, 710)).toEqual({ cols: 55, rows: 47 }) + }) + + it('answers null for a frame too narrow to fit', () => { + expect(fitDimensionsFromCell(CELL_1X, 100, 710)).toBeNull() + }) + + it('keeps an exact fit at a fractional pixel ratio whole', () => { + // 900 device px of frame over 18 px cells at 2.75 is 50 columns; the two quotients divide + // to 49.999… in floating point. + const cell = { cellWidth: 18 / 2.75, cellHeight: 40 / 2.75 } + expect(fitDimensionsFromCell(cell, 900 / 2.75, 1320 / 2.75)).toEqual({ cols: 50, rows: 33 }) + }) + + it('still falls short of a column the frame does not hold', () => { + const cell = { cellWidth: 393 / 51, cellHeight: 15 } + expect(fitDimensionsFromCell(cell, 1080 / 2.75, 600)?.cols).toBe(50) + }) +}) diff --git a/mobile/src/terminal/terminal-grid-fit.ts b/mobile/src/terminal/terminal-grid-fit.ts new file mode 100644 index 00000000000..3f8daf8c682 --- /dev/null +++ b/mobile/src/terminal/terminal-grid-fit.ts @@ -0,0 +1,28 @@ +/** + * The one fit of a cell box to the terminal frame, shared by the app and the document so a first + * subscribe and every later refit reach the same grid from the same frame width. + */ + +export type TerminalFitDimensions = { cols: number; rows: number } + +/** Below these the fit is not a terminal anyone can read, and the caller disables fit-to-phone. */ +export const MIN_FIT_COLS = 20 +export const MIN_FIT_ROWS = 8 + +// Why: frame and cell are each device pixels over the pixel ratio, so an exact fit can divide to +// 49.999…; a real shortfall is at least 1/cell-device-px (> 0.02) below the next integer. +const EXACT_FIT_TOLERANCE = 1e-6 + +/** The grid the frame (as React Native laid it out) holds at this cell box; null when too narrow. */ +export function fitDimensionsFromCell( + cell: { cellWidth: number; cellHeight: number }, + width: number, + height: number +): TerminalFitDimensions | null { + const cols = Math.floor(width / cell.cellWidth + EXACT_FIT_TOLERANCE) + if (!(cols >= MIN_FIT_COLS)) { + return null + } + const rows = Math.floor(height / cell.cellHeight + EXACT_FIT_TOLERANCE) + return { cols, rows: Math.max(MIN_FIT_ROWS, rows) } +} diff --git a/mobile/src/terminal/terminal-live-input-keyboard-reopen.test.ts b/mobile/src/terminal/terminal-live-input-keyboard-reopen.test.ts new file mode 100644 index 00000000000..c70880c1642 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input-keyboard-reopen.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from 'vitest' + +const host = vi.hoisted(() => ({ os: 'android' })) +vi.mock('react-native', () => ({ Platform: { OS: 'web' } })) +vi.mock('../platform/host-os', () => ({ hostOs: () => host.os })) + +const { reopensFocusedInputWhenKeyboardHidden } = + await import('./terminal-live-input-keyboard-reopen') + +describe('reopening a focused live input whose keyboard is hidden', () => { + it("follows the phone's OS on the page, which runs as web", () => { + host.os = 'android' + expect(reopensFocusedInputWhenKeyboardHidden()).toBe(true) + host.os = 'ios' + expect(reopensFocusedInputWhenKeyboardHidden()).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-live-input-keyboard-reopen.ts b/mobile/src/terminal/terminal-live-input-keyboard-reopen.ts new file mode 100644 index 00000000000..4fc6cf57e1a --- /dev/null +++ b/mobile/src/terminal/terminal-live-input-keyboard-reopen.ts @@ -0,0 +1,6 @@ +import { hostOs } from '../platform/host-os' + +/** Android keeps a hidden TextInput focused after Back closes the IME, so focus must blur first. */ +export function reopensFocusedInputWhenKeyboardHidden(): boolean { + return hostOs() === 'android' +} diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index 7a54c70c868..9b8a5b24b0e 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -15,7 +15,8 @@ import { 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/MobileSessionActiveContent.tsx'), + readMobileSessionRouteSource('../session/use-mobile-session-terminal-webview.ts') ].join('\n') describe('terminal viewport refit', () => { @@ -140,10 +141,10 @@ describe('terminal viewport refit', () => { // 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 measureIndex = hookSource.indexOf('measureFitDimensions', timerStart) + const fitIndex = hookSource.indexOf('ref.fitDimensions(', timerStart) expect(timerStart).toBeGreaterThanOrEqual(0) expect(coveredCheck).toBeGreaterThan(timerStart) - expect(measureIndex).toBeGreaterThan(coveredCheck) + expect(fitIndex).toBeGreaterThan(coveredCheck) expect(sessionSource).toContain('nativeChatCoveredRef: showNativeChatRef') }) @@ -152,9 +153,11 @@ describe('terminal viewport refit', () => { expect(sessionSource).toContain('tabStripVisible: terminals.length > 1') expect(sessionSource).toContain('textScale: terminalTextScale') expect(sessionSource).toContain('connState,') - expect(sessionSource).toContain('notifyTerminalFrameHeight(nextHeight)') - // One seam for both facts: on the page they come apart, because the shell shortens the WebView - // and the keyboard covers nothing the screen has to lift for. + 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)') @@ -253,7 +256,7 @@ describe('terminal viewport refit', () => { // 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)') + 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. diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index 50388ee2d7f..533b5f8d51f 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -3,6 +3,7 @@ import { AppState, Platform, useWindowDimensions, type AppStateStatus } from 're import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import type { TerminalWebViewHandle } from './TerminalWebView' +import type { TerminalFrame } from './terminal-webview-messages' import { shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-recovery' import { terminalViewportUpdate } from './mobile-terminal-operations' import { @@ -19,7 +20,8 @@ export type TerminalViewportDims = { cols: number; rows: number } type TerminalViewportRefitOptions = { activeHandleRef: RefObject terminalRefs: RefObject> - terminalFrameHeightRef: RefObject + // The terminal frame React Native laid out, unrounded; null until its first layout. + terminalFrameRef: RefObject viewportRef: RefObject viewportMeasuredRef: RefObject // Why: while native chat covers the active terminal, a refit would push phone dims into a PTY nobody on this device is viewing. @@ -31,25 +33,16 @@ type TerminalViewportRefitOptions = { tabStripVisible: boolean // Why: text size (font scale); changing it changes cell size, so the PTY must be re-fitted to a new column count. textScale: number - // Why: measured frame width; panel dock/undock or sidebar resize changes it with no window/tab change, so it re-fits the PTY. - terminalFrameWidth: number unsubscribeTerminal: (handle: string) => void subscribeToTerminal: (handle: string) => void } -type TerminalViewportRefitNotifications = { - notifyTerminalFrameHeight: (height: number) => void - notifyKeyboardVisibility: (visible: boolean) => void -} - // Why: re-measure on layout changes outside the subscribe path (tab strip, fold/rotate/resize), or a PTY renders "cut in half" (#4579). -export function useTerminalViewportRefit( - options: TerminalViewportRefitOptions -): TerminalViewportRefitNotifications { +export function useTerminalViewportRefit(options: TerminalViewportRefitOptions) { const { activeHandleRef, terminalRefs, - terminalFrameHeightRef, + terminalFrameRef, viewportRef, viewportMeasuredRef, nativeChatCoveredRef, @@ -59,7 +52,6 @@ export function useTerminalViewportRefit( connState, tabStripVisible, textScale, - terminalFrameWidth, unsubscribeTerminal, subscribeToTerminal } = options @@ -121,10 +113,12 @@ export function useTerminalViewportRefit( currentRunSeq: refitRunSeqRef.current }) void (async () => { - const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined) + await ref.awaitReady() if (!isCurrentTarget()) { return } + const frame = terminalFrameRef.current + const dims = frame ? ref.fitDimensions(frame) : null if (!dims) { return } @@ -155,7 +149,7 @@ export function useTerminalViewportRefit( rpc.updateTerminalSubscriptionViewport(handle, dims) if (outcome.applied) { // Why: updateViewport re-streams only the visible screen, so local scrollback stays wrapped at the old width — reflow it locally. - ref.reflow(dims.cols, dims.rows) + ref.reflow(dims.cols, dims.rows, frame) } return } @@ -175,7 +169,7 @@ export function useTerminalViewportRefit( [ activeHandleRef, terminalRefs, - terminalFrameHeightRef, + terminalFrameRef, viewportRef, viewportMeasuredRef, nativeChatCoveredRef, @@ -230,16 +224,26 @@ export function useTerminalViewportRefit( scheduleViewportRefit() }, [textScale, viewportMeasuredRef, scheduleViewportRefit]) - // Why: panel dock/undock or sidebar resize changes frame width with no window/tab change, so the cached viewport goes stale. - const prevFrameWidthRef = useRef(terminalFrameWidth) - useEffect(() => { - if (prevFrameWidthRef.current === terminalFrameWidth) { + // Why: panel dock/undock or a sidebar resize changes the width with no window or tab change; a + // width that still fits the PTY's grid (sub-pixel layout jitter) needs no refit. + const notifyTerminalFrameWidth = useCallback(() => { + const handle = activeHandleRef.current + const frame = terminalFrameRef.current + const fit = handle && frame ? terminalRefs.current.get(handle)?.fitDimensions(frame) : null + const grid = viewportRef.current + if (fit && grid && fit.cols === grid.cols && fit.rows === grid.rows) { return } - prevFrameWidthRef.current = terminalFrameWidth viewportMeasuredRef.current = false scheduleViewportRefit() - }, [terminalFrameWidth, viewportMeasuredRef, scheduleViewportRefit]) + }, [ + activeHandleRef, + terminalFrameRef, + terminalRefs, + viewportRef, + viewportMeasuredRef, + scheduleViewportRefit + ]) const notifyFrameHeightRefitEvent = useCallback( (event: TerminalFrameHeightRefitEvent) => { @@ -263,6 +267,18 @@ export function useTerminalViewportRefit( [notifyFrameHeightRefitEvent] ) + // Why: a renderer swap or pixel-ratio change gives the same grid a different cell box. + const notifyTerminalCellBoxChange = useCallback( + (handle: string) => { + if (handle !== activeHandleRef.current) { + return + } + viewportMeasuredRef.current = false + scheduleViewportRefit() + }, + [activeHandleRef, viewportMeasuredRef, scheduleViewportRefit] + ) + useEffect(() => { if (Platform.OS !== 'ios') { return @@ -310,5 +326,10 @@ export function useTerminalViewportRefit( } }, []) - return { notifyTerminalFrameHeight, notifyKeyboardVisibility } + return { + notifyTerminalFrameHeight, + notifyTerminalFrameWidth, + notifyKeyboardVisibility, + notifyTerminalCellBoxChange + } } diff --git a/mobile/src/terminal/terminal-web-document-mount.test.ts b/mobile/src/terminal/terminal-web-document-mount.test.ts index 9123e0f21b6..3849a02a9e7 100644 --- a/mobile/src/terminal/terminal-web-document-mount.test.ts +++ b/mobile/src/terminal/terminal-web-document-mount.test.ts @@ -20,6 +20,10 @@ import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html' */ /** Set for the length of one case; the factory throws it instead of building a document. */ let startThrows: Error | null = null +/** The text scale each document the factory built was started at, in order. */ +const startedScales: number[] = [] +/** Whether each of those documents was told to build its terminal before ready. */ +const startedBuilds: boolean[] = [] /** Set for the length of one case; the document builds its terminals here instead of xterm. */ let gridTerminal: (() => TerminalDocumentTerminal) | null = null @@ -28,6 +32,8 @@ vi.mock('./document/create-terminal-document', async (importOriginal) => { return { ...actual, createTerminalDocument: (host: Parameters[0]) => { + startedScales.push(host?.start?.().textScale ?? 1) + startedBuilds.push(host?.start?.().shown ?? true) if (startThrows) { throw startThrows } @@ -70,6 +76,8 @@ let restoreTransform: (() => void) | null = null beforeEach(() => { startThrows = null + startedScales.length = 0 + startedBuilds.length = 0 gridTerminal = null document.body.innerHTML = '' document.head.innerHTML = '' @@ -254,6 +262,34 @@ describe('the component names the cause of a start that threw', () => { }) }) +describe('the text scale a document is built at', () => { + it('is the one the view mounted with, for a reloaded document too, as on native', () => { + const host = plantHost() + startThrows = new Error('engine missing') + act(() => { + renderer = create(createElement(TerminalWebView, { textScale: 1.25 }), { + createNodeMock: () => host + }) + }) + act(() => renderer?.update(createElement(TerminalWebView, { textScale: 1.5 }))) + startThrows = null + act(() => { + renderer?.root.find((node) => node.props.accessibilityRole === 'button').props.onPress() + }) + expect(startedScales).toEqual([1.25, 1.25]) + }) + + it('builds nothing before ready on the page for a view hidden when it mounted', () => { + const host = plantHost() + act(() => { + renderer = create(createElement(TerminalWebView, { shownAtMount: false }), { + createNodeMock: () => host + }) + }) + expect(startedBuilds).toEqual([false]) + }) +}) + const CELL = { width: 7.5, height: 15 } const FIT_390 = 390 / (7.5 * 55) @@ -328,7 +364,15 @@ async function mountedOverGrid({ laidOutFirst = true } = {}) { if (laidOutFirst) { layOut(390, 600) } - send({ type: 'init', cols: 55, rows: 40, initialData: '', preserveScroll: false }) + // The app's init carries the frame it laid out. + send({ + type: 'init', + cols: 55, + rows: 40, + initialData: '', + preserveScroll: false, + frame: { width: 390, height: 600 } + }) await framesUntil(() => scales.at(-1) === FIT_390) return { mounted, scales, send, layOut, cells, grid: () => grids.at(-1)! } } @@ -407,7 +451,7 @@ describe("the page pushes its terminal frame's box into the document", () => { it('holds a fit asked for while hidden and lands it on show', async () => { const { mounted, scales, send, layOut } = await mountedOverGrid() layOut(0, 0) - send({ type: 'resize', cols: 80, rows: 40 }) + send({ type: 'resize', cols: 80, rows: 40, frame: null }) await nextFrame() await nextFrame() expect(scales.at(-1)).toBe(FIT_390) @@ -429,7 +473,7 @@ describe("the page pushes its terminal frame's box into the document", () => { layOut(0, 0) const shown = scales.length cells.measurable = false - send({ type: 'init', cols: 55, rows: 40, initialData: '', preserveScroll: false }) + send({ type: 'init', cols: 55, rows: 40, initialData: '', preserveScroll: false, frame: null }) // Past the retry loop's 60-frame cap, where a fit that did not wait commits scale 1. for (let frame = 0; frame < 75; frame++) { await nextFrame() @@ -444,6 +488,8 @@ describe("the page pushes its terminal frame's box into the document", () => { it('fits a text scale too large to resize the grid while visible', async () => { const { mounted, scales, send, layOut, grid } = await mountedOverGrid() layOut(280, 600) + // The app's refit for the new width sends its grid with the frame. + send({ type: 'resize', cols: 55, rows: 40, frame: { width: 280, height: 600 } }) await framesUntil(() => scales.at(-1) === 280 / (7.5 * 55)) // fontPxForScale(2) = 26 px: 280 / (7.5 x 2) = 18 columns, under MIN_FIT_COLS, so no resize. send({ type: 'set-font-scale', fontScale: 2 }) @@ -455,6 +501,8 @@ describe("the page pushes its terminal frame's box into the document", () => { it('refits on show after a text scale too large to resize the grid while hidden', async () => { const { mounted, scales, send, layOut } = await mountedOverGrid() layOut(280, 600) + // The app's refit for the new width sends its grid with the frame. + send({ type: 'resize', cols: 55, rows: 40, frame: { width: 280, height: 600 } }) await framesUntil(() => scales.at(-1) === 280 / (7.5 * 55)) layOut(0, 0) // fontPxForScale(2) = 26 px: 280 / (7.5 x 2) = 18 columns, under MIN_FIT_COLS, so no resize. diff --git a/mobile/src/terminal/terminal-web-document-mount.ts b/mobile/src/terminal/terminal-web-document-mount.ts index f490fba7818..b16afa80095 100644 --- a/mobile/src/terminal/terminal-web-document-mount.ts +++ b/mobile/src/terminal/terminal-web-document-mount.ts @@ -2,7 +2,7 @@ import { Terminal } from '@xterm/xterm' import { Unicode11Addon } from '@xterm/addon-unicode11' import { WebglAddon } from '@xterm/addon-webgl' import type { TerminalDocumentTerminal } from './document/document-terminal-shape' -import type { TerminalViewportChange } from './document/document-host-seams' +import type { TerminalDocumentStart, TerminalViewportChange } from './document/document-host-seams' import { TERMINAL_DOCUMENT_ELEMENT_STYLE, TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html' import { scopeStyleToHost } from '../style-scoping/document-style-scoping' import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated' @@ -98,15 +98,17 @@ function createPageWebglAddon(onFallback: (reason: string) => void) { * Synchronous, because the factory is a static import and building a document is a function call. * A caller's cleanup can therefore never arrive before there is something to clean up. */ +/** What the view fixed when it mounted, for every document it builds. */ export function mountTerminalWebDocument( host: HTMLElement, - receive: (message: Record) => void + receive: (message: Record) => void, + start: TerminalDocumentStart = { textScale: 1, shown: true } ): TerminalWebDocument { ensureDocumentStyle() host.classList.add(HOST_CLASS) host.innerHTML = TERMINAL_DOCUMENT_MARKUP const viewport = pageViewport(host) - const started = startDocumentOrGiveTheHostBack(host, receive, viewport) + const started = startDocumentOrGiveTheHostBack(host, receive, start, viewport) return { send: (command) => { @@ -134,10 +136,11 @@ export function mountTerminalWebDocument( function startDocumentOrGiveTheHostBack( host: HTMLElement, receive: (message: Record) => void, + start: TerminalDocumentStart, viewport: PageViewport ) { try { - return startPageDocument(host, receive, viewport) + return startPageDocument(host, receive, start, viewport) } catch (error) { host.innerHTML = '' host.classList.remove(HOST_CLASS) @@ -189,6 +192,7 @@ function pageViewport(host: HTMLElement) { function startPageDocument( host: HTMLElement, receive: (message: Record) => void, + start: TerminalDocumentStart, viewport: PageViewport ) { // Written by this document's own reporter: `startHostNotify` installs it through the seam below, @@ -236,6 +240,8 @@ function startPageDocument( // fail. Here the engine is the import above, so it is here or this module did not load. hasEngine: () => true, + start: () => start, + // The window here is the whole page, header and dock included; the grid is shown in the host. viewportRect: viewport.rect, diff --git a/mobile/src/terminal/terminal-webview-cell-box.test.ts b/mobile/src/terminal/terminal-webview-cell-box.test.ts new file mode 100644 index 00000000000..602bddd0f37 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-cell-box.test.ts @@ -0,0 +1,200 @@ +import { createElement, createRef } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalWebView } from './TerminalWebView' +import type { TerminalWebViewHandle } from './terminal-webview-contract' + +const nativeWebViewMethods = vi.hoisted(() => ({ + postMessage: vi.fn<(message: string) => void>(), + reload: vi.fn<() => void>() +})) + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active' }, + Platform: { OS: 'android' }, + Pressable: 'Pressable', + StyleSheet: { + absoluteFillObject: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0 }, + create: (styles: unknown) => styles + }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-webview', async () => { + const React = await import('react') + const WebView = React.forwardRef((props: Record, ref) => { + React.useImperativeHandle(ref, () => nativeWebViewMethods) + return React.createElement('WebView', props) + }) + return { WebView, default: WebView } +}) + +vi.mock('lucide-react-native', () => ({ RefreshCw: 'RefreshCw' })) + +const FRAME = { width: 427, height: 710 } +const scale = 1 +const cellAt = (fontScale: number, cellWidth = 23 / 3) => ({ fontScale, cellWidth, cellHeight: 15 }) + +const renderers: ReactTestRenderer[] = [] +afterEach(() => { + act(() => renderers.splice(0).forEach((renderer) => renderer.unmount())) + nativeWebViewMethods.postMessage.mockClear() + nativeWebViewMethods.reload.mockClear() + vi.useRealTimers() +}) + +function mount(textScale = scale) { + const ref = createRef() + const onCellBoxChange = vi.fn() + const onWebReady = vi.fn<() => void>() + let renderer: ReactTestRenderer | undefined + act(() => { + renderer = create( + createElement(TerminalWebView, { ref, textScale, onCellBoxChange, onWebReady }) + ) + }) + renderers.push(renderer!) + const handle = () => { + if (!ref.current) { + throw new Error('no handle') + } + return ref.current + } + const webView = () => renderer!.root.find((node) => typeof node.props.onMessage === 'function') + const notify = (payload: Record) => { + act(() => { + webView().props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } }) + }) + } + return { handle, notify, onCellBoxChange, onWebReady, webView } +} + +function postedTypes(): unknown[] { + return nativeWebViewMethods.postMessage.mock.calls.map(([message]) => JSON.parse(message).type) +} + +const cellBoxNotify = (cellWidth: number, refit: boolean) => ({ + type: 'cell-box', + cellBox: cellAt(scale, cellWidth), + refit +}) + +describe('the cell box xterm laid out', () => { + it('sizes a fit from web-ready, which the document sends once its terminal is built', () => { + const { handle, notify } = mount() + expect(handle().fitDimensions(FRAME)).toBeNull() + notify({ type: 'web-ready', cellBox: cellAt(scale) }) + expect(handle().fitDimensions(FRAME)).toEqual({ cols: 55, rows: 47 }) + expect(postedTypes()).not.toContain('measure') + }) + + it('fits each box the document reports, and refits only when the document says so', () => { + // The document holds the grid and decides; its tests pin the one-refit bound. + const { handle, notify, onCellBoxChange } = mount() + notify({ type: 'web-ready', cellBox: cellAt(scale) }) + notify(cellBoxNotify(7.9, false)) + expect(onCellBoxChange).not.toHaveBeenCalled() + expect(handle().fitDimensions(FRAME)).toEqual({ cols: 54, rows: 47 }) + notify(cellBoxNotify(7.8, true)) + expect(onCellBoxChange).toHaveBeenCalledTimes(1) + expect(handle().fitDimensions(FRAME)).toEqual({ cols: 54, rows: 47 }) + }) + + it('fits from the box the current document reported, not one an earlier document did', () => { + const { handle, notify, webView } = mount() + notify({ type: 'web-ready', cellBox: cellAt(scale) }) + act(() => webView().props.onLoadStart()) + notify({ type: 'web-ready', cellBox: null }) + expect(handle().fitDimensions(FRAME)).toBeNull() + }) + + it("fits only from a ready box at the app's text scale", () => { + const { handle, notify, webView } = mount(1.5) + // A document built at mount keeps that scale; a reload after a text-size change reports it. + notify({ type: 'web-ready', cellBox: cellAt(1) }) + expect(handle().fitDimensions(FRAME)).toBeNull() + act(() => webView().props.onLoadStart()) + notify({ type: 'web-ready', cellBox: cellAt(1.5) }) + expect(handle().fitDimensions(FRAME)).toEqual({ cols: 55, rows: 47 }) + }) + + it('fits in the app from the reported box, and sends the frame with every grid', () => { + const { handle, notify } = mount() + notify({ type: 'web-ready', cellBox: cellAt(scale) }) + expect(handle().fitDimensions({ width: 427.5, height: 0 })).toBeNull() + expect(handle().fitDimensions({ width: 427.5, height: 710 })).toEqual({ cols: 55, rows: 47 }) + const frame = { width: 427.5, height: 710 } + handle().init({ cols: 55, rows: 47, initialData: '', frame }) + handle().resize(55, 47, frame) + handle().reflow(55, 47, frame) + const grids = nativeWebViewMethods.postMessage.mock.calls + .map(([message]) => JSON.parse(message)) + .filter((message) => ['init', 'resize', 'reflow'].includes(message.type)) + expect(grids.map((message) => message.frame)).toEqual([frame, frame, frame]) + expect(postedTypes()).not.toContain('measure') + }) + + it('sends nothing before a ready: a load start drops what was queued', () => { + const { handle, notify, webView } = mount() + handle().init({ cols: 55, rows: 47, initialData: 'snapshot', frame: null }) + expect(postedTypes()).toEqual([]) + act(() => webView().props.onLoadStart()) + notify({ type: 'web-ready', cellBox: cellAt(scale) }) + expect(postedTypes()).not.toContain('init') + }) + + it('reloads the same view when its content process ends', () => { + const { webView } = mount() + const view = webView() + act(() => view.props.onContentProcessDidTerminate({ nativeEvent: {} })) + expect(nativeWebViewMethods.reload).toHaveBeenCalledTimes(1) + expect(webView()).toBe(view) + }) + + it('drops queued and coalesced commands on a reload or a lost content process', () => { + vi.useFakeTimers() + const { handle, notify, webView } = mount() + act(() => webView().props.onLoadStart()) + handle().write('for the reloaded document') + act(() => webView().props.onLoadStart()) + handle().write('for the lost content process') + act(() => webView().props.onContentProcessDidTerminate({ nativeEvent: {} })) + notify({ type: 'web-ready', cellBox: cellAt(scale) }) + act(() => { + vi.runAllTimers() + }) + expect(postedTypes()).not.toContain('write') + handle().write('for the current document') + act(() => { + vi.runAllTimers() + }) + expect(postedTypes()).toContain('write') + }) + + it('tells the document the app text scale before it builds its terminal', () => { + const { webView } = mount(1.25) + const html: string = webView().props.source.html + const start = html.indexOf('window.__orcaTerminalTextScale = 1.25;') + expect(start).toBeGreaterThan(-1) + // Ahead of the document script, in the page itself, so no platform can run it late. + expect(start).toBeLessThan(html.indexOf('startTerminalDocument')) + }) + + it('keeps the source it mounted with across renders, so a new text scale does not reload', () => { + const { webView } = mount(1) + const source = webView().props.source + act(() => renderers[0]!.update(createElement(TerminalWebView, { textScale: 1.5 }))) + expect(webView().props.source).toBe(source) + }) + + it('tells a document whose view was hidden at mount not to build before ready', () => { + let renderer: ReactTestRenderer | undefined + act(() => { + renderer = create(createElement(TerminalWebView, { shownAtMount: false })) + }) + renderers.push(renderer!) + const webView = renderer!.root.find((node) => typeof node.props.onMessage === 'function') + expect(webView.props.source.html).toContain('window.__orcaTerminalShown = false;') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index e2082192628..1c9960ab1ec 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -1,6 +1,8 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' import type { StyleProp, ViewStyle } from 'react-native' +import type { TerminalFitDimensions } from './terminal-grid-fit' +import type { TerminalFrame } from './terminal-webview-messages' type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' @@ -77,6 +79,8 @@ export type TerminalSelectionEvents = { // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it // here so the app persists it and keeps Settings + other panes in sync. onTextScaleChange?: (scale: number) => void + // xterm laid out a different cell box at the same grid (renderer swap, pixel-ratio change). + onCellBoxChange?: () => void } export type TerminalWebViewProps = { @@ -85,35 +89,42 @@ export type TerminalWebViewProps = { // Why: baseline zoom multiplier applied on top of fit-to-width scale; raw // xterm fontSize alone cannot drive apparent size because fitting cancels it. textScale?: number + // Why: only a view shown when it mounts builds its terminal before ready (one WebGL context each). + shownAtMount?: boolean onWebReady?: () => void onEngineError?: (message: string) => void } & TerminalSelectionEvents +/** One init: the grid, the snapshot it replays, and the frame the grid was fitted to. */ +export type TerminalInit = { + cols: number + rows: number + initialData?: string + preserveScroll?: boolean + oscLinks?: TerminalOscLinkRange[] + frame: TerminalFrame | null +} + export type TerminalWebViewHandle = { // Why: iOS can preserve the native view while discarding its JS/backing-store // state; foreground recovery must wait for the document to answer before replay. prepareForForegroundRecovery: () => void write: (data: string) => void - init: ( - cols: number, - rows: number, - initialData?: string, - preserveScroll?: boolean, - oscLinks?: TerminalOscLinkRange[] - ) => void - resize: (cols: number, rows: number) => void + init: (init: TerminalInit) => void + resize: (cols: number, rows: number, frame: TerminalFrame | null) => void // Why: reflow the local xterm buffer (scrollback included) to a new width // after a server-side PTY reflow, so older wrapped lines rewrap to match the // latest output. No-op on the alternate screen. - reflow: (cols: number, rows: number) => void + reflow: (cols: number, rows: number, frame: TerminalFrame | null) => void clear: () => void - measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> + /** The grid this frame holds at the cell box the document reported; null without one at this text size. */ + fitDimensions: (frame: TerminalFrame) => TerminalFitDimensions | null resetZoom: () => void cancelSelect: () => void doSelectAll: () => void // Why: lets callers await the WebView-side `init` rAF chain (term.open - // → renderService population → first paint) so a follow-up measure - // doesn't race ahead and find term=null or cellWidth=0. Resolves on - // the next 'ready' notify after the most recent init. + // → renderService population → first paint → the init's cell box) so a + // follow-up fit reads that box. Resolves on the next 'ready' notify after + // the most recent init. awaitReady: () => Promise } diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 01d0e577a86..65bbff60d35 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -21,4 +21,20 @@ export const XTERM_HTML = [ TERMINAL_HTML_DOCUMENT_CLOSE ].join('') -export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } +/** + * The WebView's document, starting as its view mounted: at this text scale, and whether it was + * shown. Written into the page ahead of the document script, which reads them as it starts. + */ +export function xtermWebViewSource(start: { textScale: number; shown: boolean }) { + const startValues = + `window.__orcaTerminalTextScale = ${JSON.stringify(start.textScale)};\n` + + `window.__orcaTerminalShown = ${JSON.stringify(start.shown)};\n` + return { + html: [ + TERMINAL_HTML_DOCUMENT_SHELL, + startValues, + TERMINAL_DOCUMENT_SCRIPT, + TERMINAL_HTML_DOCUMENT_CLOSE + ].join('') + } +} diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts index 35c6e991b68..ca82f173917 100644 --- a/mobile/src/terminal/terminal-webview-messages.ts +++ b/mobile/src/terminal/terminal-webview-messages.ts @@ -1,6 +1,9 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' +/** The frame React Native laid out, sent with each grid so the document fits a text-size change to it. */ +export type TerminalFrame = { width: number; height: number } + export type TerminalWebViewCommand = | { type: 'ping'; id?: number } | { type: 'write'; id?: number; data: string } @@ -16,12 +19,12 @@ export type TerminalWebViewCommand = // Why: width-reflow re-streams replay the same content rewrapped at new // cols; preserve the reader's scroll position instead of jumping to bottom. preserveScroll?: boolean + frame: TerminalFrame | null } | { type: 'set-font-scale'; id?: number; fontScale: number } - | { type: 'resize'; id?: number; cols: number; rows: number } - | { type: 'reflow'; id?: number; cols: number; rows: number } + | { type: 'resize'; id?: number; cols: number; rows: number; frame: TerminalFrame | null } + | { type: 'reflow'; id?: number; cols: number; rows: number; frame: TerminalFrame | null } | { type: 'clear'; id?: number } - | { type: 'measure'; id?: number; containerHeight?: number } | { type: 'reset-zoom'; id?: number } | { type: 'cancel-select'; id?: number } | { type: 'do-select-all'; id?: number } diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts index 49b8fb63539..48de712bcaa 100644 --- a/mobile/src/terminal/terminal-webview-query-reply.test.ts +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -119,7 +119,7 @@ describe('mobile terminal query replies', () => { 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 === 'measure')", clearStart) + 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-ready-promises.ts b/mobile/src/terminal/terminal-webview-ready-promises.ts index fde27fe2846..0eab14849dc 100644 --- a/mobile/src/terminal/terminal-webview-ready-promises.ts +++ b/mobile/src/terminal/terminal-webview-ready-promises.ts @@ -1,35 +1,24 @@ import { useMemo } from 'react' -import type { TerminalWebViewCommand } from './terminal-webview-messages' /** - * The two promises the terminal handle hands out, and the notifies that settle them. + * The ready promise the terminal handle hands out, and the notify that settles it. * * `awaitReady` waits for the document's `init` rAF chain — `term.open`, renderService population, - * first paint — because a measure that runs synchronously after init finds `term` null or cells - * of size zero. `measureFitDimensions` waits for the document's answer. Both are promises held - * across a message round trip, both have a timeout for the case where the document never answers, - * and both are the same on either host, so they live here rather than in the controller that owns - * the readiness handshake. + * first paint, and the cell box that init reports — so a fit after it reads the init's own box. It + * has a timeout for a document that never answers, and is the same on either host. */ const READY_TIMEOUT_MS = 3000 -const MEASURE_TIMEOUT_MS = 2000 -/** Below these the fit is not a terminal anyone can read, and the caller disables fit-to-phone. */ -const MIN_FIT_COLS = 20 -const MIN_FIT_ROWS = 8 - -export type TerminalFitDimensions = { cols: number; rows: number } export function createTerminalWebViewReadyPromises() { let readyPromise: Promise | null = null let readyResolve: (() => void) | null = null - let measureResolve: ((result: TerminalFitDimensions | null) => void) | null = null /** * Arms a fresh ready promise, resolving any prior one first. * * Why: an awaiter from the previous generation would otherwise sit on the timeout below — each - * leaked timer and closure pinned an awaiting measure caller for the full 3s under rapid + * leaked timer and closure pinned an awaiting fit caller for the full 3s under rapid * re-init (orientation change, multiple resubscribes), delaying cold-start fit chains. */ function armReady() { @@ -70,47 +59,7 @@ export function createTerminalWebViewReadyPromises() { }) } - function measure( - send: (command: TerminalWebViewCommand) => void, - containerHeight?: number - ): Promise { - return new Promise((resolve) => { - measureResolve?.(null) - let timeout: ReturnType | null = null - const finish = (result: TerminalFitDimensions | null) => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - if (measureResolve === finish) { - measureResolve = null - } - resolve(result) - } - measureResolve = finish - send({ type: 'measure', containerHeight }) - // Why: if the document doesn't respond (e.g., xterm failed to load), resolve null so the - // caller can disable Fit to Phone rather than hanging indefinitely. - timeout = setTimeout(() => { - if (measureResolve === finish) { - finish(null) - } - }, MEASURE_TIMEOUT_MS) - }) - } - - function resolveMeasure(msg: Record) { - const resolve = measureResolve - measureResolve = null - if (!resolve) { - return - } - const cols = typeof msg.cols === 'number' ? msg.cols : null - const rows = typeof msg.rows === 'number' ? msg.rows : null - resolve(cols && rows && cols >= MIN_FIT_COLS && rows >= MIN_FIT_ROWS ? { cols, rows } : null) - } - - return { armReady, awaitReady, measure, resolveMeasure, resolveReady } + return { armReady, awaitReady, resolveReady } } export function useTerminalWebViewReadyPromises() { diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts index 5e29cb06c8e..82948f541eb 100644 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ b/mobile/src/terminal/terminal-webview-reflow.test.ts @@ -50,16 +50,13 @@ describe('terminal WebView reflow', () => { 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 })") + expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows, frame })") }) it('does not locally resize hidden WebViews to a one-column grid', () => { - // The floor is a constant of the module that fits the grid, and both readers import it. - expect(DOCUMENT_SOURCE).toContain('export const MIN_FIT_COLS = 20') - expect(DOCUMENT_SOURCE).toContain('if (cols < MIN_FIT_COLS) {') - expect(DOCUMENT_SOURCE).toContain("flog(scope, 'measure-skip-small-width'") - expect(DOCUMENT_SOURCE).toContain( - "notify(scope, { type: 'measure-result', cols: null, rows: null })" - ) + // 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 index f62bfdf1ee2..bb0cc649412 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -140,14 +140,10 @@ describe('TerminalWebView scroll routing', () => { expect(source).toContain('pendingMessages.clear()') }) - it('clears WebView await timers when the real response wins', () => { - // C7.5 moved both promises into `terminal-webview-ready-promises.ts`, which both components - // reach through the controller; the two blocks are the same code in their new home. - const measureBlock = sliceBetween('function measure(', 'function resolveMeasure') - expect(measureBlock).toContain('clearTimeout(timeout)') - expect(measureBlock).toContain('measureResolve === finish') - - const readyBlock = sliceBetween('async function awaitReady()', 'function measure(') + 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') }) diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 9d2403098ca..20b2d2a0afd 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -8,10 +8,6 @@ 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 terminalHtmlModuleSource = readFileSync( - join(import.meta.dirname, 'terminal-webview-html.ts'), - 'utf8' -) const terminalHtmlDocumentShellSource = readFileSync( join(import.meta.dirname, 'terminal-webview-html', 'document-shell.ts'), 'utf8' @@ -65,11 +61,9 @@ describe('TerminalWebView text zoom', () => { const end = terminalWebViewSource.indexOf('/>', start) expect(end).toBeGreaterThan(start) const webViewProps = terminalWebViewSource.slice(start, end) - expect(terminalHtmlModuleSource).toContain( - 'export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }' - ) - expect(webViewProps).toContain('source={XTERM_WEBVIEW_SOURCE}') - expect(webViewProps).not.toContain('source={{ html: XTERM_HTML }}') + // 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', () => { @@ -123,7 +117,7 @@ describe('TerminalWebView text zoom', () => { 'const replayData = normalizeInitialData(initialData)' ) const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {") - const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart) + const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'reset-zoom')", clearStart) expect(initStart).toBeGreaterThanOrEqual(0) expect(initReplay).toBeGreaterThan(initStart) expect(clearStart).toBeGreaterThanOrEqual(0) @@ -139,8 +133,8 @@ describe('TerminalWebView text zoom', () => { 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('scope.term.open(scope.surface!)') - const unicode = terminalHtmlSource.indexOf("scope.term.unicode.activeVersion = '11'") + 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) diff --git a/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts b/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts index 22e76581a0e..39615ef3692 100644 --- a/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts +++ b/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts @@ -50,12 +50,12 @@ describe('terminal write coalescer boundaries', () => { view.coalescer.write('stale-pre-snapshot') // init() boundary as wired in TerminalWebView: clear the coalescer, then post init. view.coalescer.clear() - view.postMessage({ type: 'init', cols: 80, rows: 24, initialData: 'snapshot' }) + view.postMessage({ type: 'init', cols: 80, rows: 24, initialData: 'snapshot', frame: null }) vi.runOnlyPendingTimers() expect(view.delivered).toEqual([ { type: 'write', data: 'a' }, - { type: 'init', cols: 80, rows: 24, initialData: 'snapshot' } + { type: 'init', cols: 80, rows: 24, initialData: 'snapshot', frame: null } ]) const initIndex = view.delivered.findIndex((msg) => msg.type === 'init') expect(initIndex).toBeGreaterThanOrEqual(0) @@ -75,7 +75,7 @@ describe('terminal write coalescer boundaries', () => { // Recovery re-init: coalescer.clear() cancels any pending timer synchronously, // so nothing can flush after this point; the queued init supersedes the flush. view.coalescer.clear() - view.postMessage({ type: 'init', cols: 80, rows: 24, initialData: 'snapshot' }) + view.postMessage({ type: 'init', cols: 80, rows: 24, initialData: 'snapshot', frame: null }) view.readiness.webReady = true view.pendingMessages.flush(view.send) @@ -84,7 +84,7 @@ describe('terminal write coalescer boundaries', () => { expect(view.delivered).toEqual([ { type: 'write', data: 'live' }, { type: 'write', data: 'buffered-mid-recovery' }, - { type: 'init', cols: 80, rows: 24, initialData: 'snapshot' } + { type: 'init', cols: 80, rows: 24, initialData: 'snapshot', frame: null } ]) // Invariant: no write reaches the document after the recovery init. const initIndex = view.delivered.findIndex((msg) => msg.type === 'init') @@ -124,7 +124,9 @@ describe('terminal write coalescer boundaries', () => { 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('initialData?: string,') + 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) @@ -142,7 +144,9 @@ describe('terminal write coalescer boundaries', () => { 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) {`) + 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()') diff --git a/mobile/src/terminal/use-terminal-webview-controller.ts b/mobile/src/terminal/use-terminal-webview-controller.ts index c2c0575f548..8ca48f55a65 100644 --- a/mobile/src/terminal/use-terminal-webview-controller.ts +++ b/mobile/src/terminal/use-terminal-webview-controller.ts @@ -1,9 +1,14 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' -import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' -import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract' +import { readTerminalCellBox, type TerminalCellBox } from './terminal-cell-box' +import { fitDimensionsFromCell } from './terminal-grid-fit' +import type { + TerminalInit, + TerminalWebViewHandle, + TerminalWebViewProps +} from './terminal-webview-contract' import { useTerminalWebViewEngineErrorState } from './terminal-webview-engine-error-state' import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog' -import type { TerminalWebViewCommand } from './terminal-webview-messages' +import type { TerminalFrame, TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch' import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing' @@ -15,7 +20,7 @@ import { createTerminalWriteCoalescer } from './terminal-write-coalescer' * * The document is the same program on both platforms — inside the WebView it is the generated * script, on the page it is the modules that script is generated from — so the readiness - * handshake, the pending queue, the write coalescer, the ready and measure promises and the whole + * handshake, the pending queue, the write coalescer, the ready promise and the whole * imperative handle are the same too. What differs is only how a command reaches the document and * how a notify comes back: a `postMessage` across the WebView bridge, or a direct call. * @@ -61,7 +66,8 @@ export function useTerminalWebViewController( onTerminalTap, onFileTap, onOpenUrl, - onTextScaleChange + onTextScaleChange, + onCellBoxChange } = props const { pingsOnForegroundRecovery, post } = transport const isWebReadyRef = useRef(false) @@ -70,9 +76,11 @@ export function useTerminalWebViewController( const pendingPingIdRef = useRef(null) const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme]) // Why: each init() call posts 'init' to the document and arms a fresh ready promise. The - // document's init() rAF chain ends with a 'ready' notify that resolves it. measureFitDimensions - // awaits this so it doesn't race ahead of term.open() / renderService population. + // document's init() rAF chain ends with a 'ready' notify that resolves it. A fit awaits this so + // it reads the box the init reported. const promises = useTerminalWebViewReadyPromises() + // The box the current document last reported; its re-reports cover a text-size change. + const cellBoxRef = useRef(null) const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } = useTerminalWebViewEngineErrorState(onEngineError) const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog( @@ -148,6 +156,8 @@ export function useTerminalWebViewController( routeTerminalQueryReply(msg, onTerminalQueryReply) if (msg.type === 'web-ready') { + // Why: nothing subscribes before ready, so a ready's box only sizes the subscribe after it. + cellBoxRef.current = readTerminalCellBox(msg) confirmWebReady(true) } else if ( msg.type === 'pong' && @@ -157,11 +167,14 @@ export function useTerminalWebViewController( confirmWebReady(false) } else if (msg.type === 'ready') { // Why: the document's init() rAF chain has run — term is open, renderService is - // populated, first paint has happened. Resolve any pending awaitReady() so a queued - // measure can now safely read cell dims. + // populated, first paint has happened, and its box was reported. Resolve any pending + // awaitReady() so a queued fit reads that box. promises.resolveReady() - } else if (msg.type === 'measure-result') { - promises.resolveMeasure(msg) + } else if (msg.type === 'cell-box') { + cellBoxRef.current = readTerminalCellBox(msg) + if (msg.refit === true) { + onCellBoxChange?.() + } } else { dispatchTerminalWebViewNotification(msg, { reportEngineError, @@ -194,7 +207,8 @@ export function useTerminalWebViewController( onTerminalTap, onFileTap, onOpenUrl, - onTextScaleChange + onTextScaleChange, + onCellBoxChange ] ) @@ -209,6 +223,7 @@ export function useTerminalWebViewController( pendingPingIdRef.current = null pendingMessages.clear() writeCoalescer.clear() + cellBoxRef.current = null armWebReadyWatchdog() }, [armWebReadyWatchdog, pendingMessages, writeCoalescer]) @@ -222,6 +237,17 @@ export function useTerminalWebViewController( postMessage({ type: 'set-font-scale', fontScale: textScale }) }, [postMessage, textScale]) + const fitDimensions = useCallback( + (frame: TerminalFrame) => { + const cell = cellBoxRef.current + // Why: a box at another scale (a reload keeps the mount's) fits nothing; the route stays unmeasured. + return cell && cell.fontScale === textScale && frame.width > 0 && frame.height > 0 + ? fitDimensionsFromCell(cell, frame.width, frame.height) + : null + }, + [textScale] + ) + const handle = useMemo( () => ({ prepareForForegroundRecovery() { @@ -237,13 +263,7 @@ export function useTerminalWebViewController( write(data: string) { writeCoalescer.write(data) }, - init( - cols: number, - rows: number, - initialData?: string, - preserveScroll?: boolean, - oscLinks?: TerminalOscLinkRange[] - ) { + init({ cols, rows, initialData, preserveScroll, oscLinks, frame }: TerminalInit) { // Why: arm a fresh ready promise BEFORE posting init. The document resolves it via the // 'ready' notify at the end of its rAF chain. promises.armReady() @@ -258,28 +278,24 @@ export function useTerminalWebViewController( oscLinks, terminalTheme, fontScale: textScale, - preserveScroll + preserveScroll, + frame }) }, - resize(cols: number, rows: number) { + resize(cols: number, rows: number, frame: TerminalFrame | null) { // Why: resize/reflow must observe all prior writes or bytes reorder. writeCoalescer.flushNow() - postMessage({ type: 'resize', cols, rows }) + postMessage({ type: 'resize', cols, rows, frame }) }, - reflow(cols: number, rows: number) { + reflow(cols: number, rows: number, frame: TerminalFrame | null) { writeCoalescer.flushNow() - postMessage({ type: 'reflow', cols, rows }) + postMessage({ type: 'reflow', cols, rows, frame }) }, clear() { writeCoalescer.clear() postMessage({ type: 'clear' }) }, - measureFitDimensions(containerHeight?: number) { - if (!isWebReadyRef.current) { - return Promise.resolve(null) - } - return promises.measure(sendToDocument, containerHeight) - }, + fitDimensions, resetZoom() { postMessage({ type: 'reset-zoom' }) }, @@ -295,6 +311,7 @@ export function useTerminalWebViewController( }), [ armWebReadyWatchdog, + fitDimensions, pingsOnForegroundRecovery, postMessage, promises, diff --git a/mobile/src/test-support/rpc-recording/adapters/terminal-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/terminal-mount-adapters.ts index 0cb0d559cbc..8af181a469b 100644 --- a/mobile/src/test-support/rpc-recording/adapters/terminal-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/terminal-mount-adapters.ts @@ -12,9 +12,10 @@ function terminalWebViewHandle( dims: { cols: number; rows: number } ) { return { - measureFitDimensions: (frameHeight?: number) => { - effect('measure-fit', { frameHeight: frameHeight ?? null }) - return Promise.resolve(dims) + awaitReady: () => Promise.resolve(), + fitDimensions: (frame: { height: number }) => { + effect('measure-fit', { frameHeight: frame.height }) + return dims }, reflow: (cols: number, rows: number) => effect('reflow', { cols, rows }) } @@ -106,7 +107,7 @@ export function terminalMountAdapters( notifications = useRefit({ activeHandleRef: { current: HANDLE }, terminalRefs, - terminalFrameHeightRef: { current: 600 }, + terminalFrameRef: { current: { width: 390, height: 600 } }, viewportRef, viewportMeasuredRef, nativeChatCoveredRef: { current: false }, @@ -116,7 +117,6 @@ export function terminalMountAdapters( connState, tabStripVisible: true, textScale: 1, - terminalFrameWidth: 390, unsubscribeTerminal: (handle: string) => effect('unsubscribe-terminal', { handle }), subscribeToTerminal: (handle: string) => effect('subscribe-terminal', { handle }) } as unknown as Parameters[0]) diff --git a/mobile/src/transport/client-context.web.test.tsx b/mobile/src/transport/client-context.web.test.tsx index c322d07ab7e..1efc5771dd9 100644 --- a/mobile/src/transport/client-context.web.test.tsx +++ b/mobile/src/transport/client-context.web.test.tsx @@ -2,10 +2,7 @@ import type { ReactElement } from 'react' import { act, create } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BRIDGE_PROTOCOL_VERSION } from '../mobile-web-shell/bridge/bridge-envelope' -import { - BRIDGE_PAGE_CLIENT_ID, - BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT -} from '../mobile-web-shell/bridge/bridge-page-client-identity' +import { BRIDGE_PAGE_CLIENT_ID } from '../mobile-web-shell/bridge/bridge-page-client-identity' import { createShellPageClient } from '../mobile-web-shell/bridge/page-bootstrap' import type { BridgeRpcClient } from '../mobile-web-shell/bridge/bridge-rpc-client' import type { RpcClientContextValue } from './rpc-client-context-contract' @@ -35,8 +32,7 @@ const INIT = { lastInboundAt: 1800, generation: 5 }, - grants: { rpc: { maxPendingRequests: 64, maxSubscriptions: 32 }, native: [] }, - accepts: [BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT] + grants: { rpc: { maxPendingRequests: 64, maxSubscriptions: 32 }, native: [] } } /** What the page mounted, and what it holds — the two things the provider decides. */ @@ -172,25 +168,6 @@ describe('the page provider', () => { expect(first).toBe(BRIDGE_PAGE_CLIENT_ID) }) - it('claims nothing at all when the shell never said it performs the swap', () => { - const channel = installChannel() - const client = createShellPageClient() - if (client === null) { - throw new Error('no channel installed') - } - // An `init` that landed, naming no swap — not a client with no session at all, which would - // answer null for a reason that has nothing to do with the capability. - channel.deliver({ ...INIT, accepts: [] }) - act(() => { - create(render(client)) - }) - expect(readContext().getState('host-a')).toBe('connected') - - // An older shell forwards what the page sent, so a placeholder would reach the host and be - // refused as a spoof. Nothing claimed is the honest answer, and it is what shipped before. - expect(readContext().getClientId('host-a')).toBe(null) - }) - it('carries a state change from the shell to the screens watching it', () => { const channel = installChannel() const client = createReadyClient(channel.deliver) diff --git a/mobile/src/transport/client-context.web.tsx b/mobile/src/transport/client-context.web.tsx index 463aff873b2..a1606ba9853 100644 --- a/mobile/src/transport/client-context.web.tsx +++ b/mobile/src/transport/client-context.web.tsx @@ -3,10 +3,7 @@ // side of the bridge already did, and this provider only carries what it holds across the boundary. import { createContext, useContext, useMemo, useRef, type ReactNode } from 'react' import type { BridgeRpcClient } from '../mobile-web-shell/bridge/bridge-rpc-client' -import { - BRIDGE_PAGE_CLIENT_ID, - BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT -} from '../mobile-web-shell/bridge/bridge-page-client-identity' +import { BRIDGE_PAGE_CLIENT_ID } from '../mobile-web-shell/bridge/bridge-page-client-identity' import type { ConnectionState, HostProfile } from './types' import type { RpcClientContextValue } from './rpc-client-context-contract' @@ -61,17 +58,8 @@ export function RpcClientProvider({ getState: () => client.getState(), // Nothing mounts before `init`, so the page's state is never the unknown this answers null for. getKnownState: () => client.getState(), - /** - * A placeholder the shell swaps for this device's real identity, never the credential itself. - * - * Only when the shell said it performs the swap; a shell that did not leaves this null, which - * is no terminal and no live input, because the session route refuses to subscribe or send - * without one — but a placeholder the host read itself would be refused as a spoof. - */ - getClientId: () => - client.getShellSession()?.accepts.includes(BRIDGE_PAGE_CLIENT_IDENTITY_ACCEPT) === true - ? BRIDGE_PAGE_CLIENT_ID - : null, + /** A placeholder the shell swaps for this device's real identity, never the credential. */ + getClientId: () => BRIDGE_PAGE_CLIENT_ID, getReconnectAttempt: () => client.getReconnectAttempt(), getLastConnectedAt: () => client.getLastConnectedAt(), // The page reaches its host through the shell bridge, which rides whatever path the RN diff --git a/mobile/src/transport/force-reconnect-page-seam.test.tsx b/mobile/src/transport/force-reconnect-page-seam.test.tsx index 8aa8aee556e..38d07b0d44b 100644 --- a/mobile/src/transport/force-reconnect-page-seam.test.tsx +++ b/mobile/src/transport/force-reconnect-page-seam.test.tsx @@ -27,8 +27,7 @@ const INIT = { lastInboundAt: 1800, generation: 5 }, - grants: { rpc: { maxPendingRequests: 64, maxSubscriptions: 32 }, native: [] }, - accepts: [] + grants: { rpc: { maxPendingRequests: 64, maxSubscriptions: 32 }, native: [] } } afterEach(() => { diff --git a/mobile/src/transport/mobile-web-bundle-compat.test.ts b/mobile/src/transport/mobile-web-bundle-compat.test.ts index 639d41ef7db..4fea3dab176 100644 --- a/mobile/src/transport/mobile-web-bundle-compat.test.ts +++ b/mobile/src/transport/mobile-web-bundle-compat.test.ts @@ -25,6 +25,7 @@ function manifest( schemaVersion: 1, runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 2, + pageVersion: 1, ...overrides } } @@ -189,3 +190,40 @@ describe('evaluateMobileWebBundleCompat', () => { expect(SUPPORTED_MOBILE_WEB_BUNDLE_SCHEMA_VERSIONS).toContain(MOBILE_WEB_BUNDLE_SCHEMA_VERSION) }) }) + +/** The shell requires a page at least as new as itself; an older desktop is walled, never served. */ +describe('the page floor', () => { + const tooOld = (pageVersion: number) => ({ + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'desktop', + pageVersion, + requiredPageVersion: 1 + }) + + it('walls a desktop whose page is older than this shell, as a desktop to update', () => { + expect(evaluate({ manifest: manifest({ pageVersion: 0 }) })).toEqual(tooOld(0)) + }) + + it('reads a manifest with no page version as older than the floor', () => { + const { schemaVersion, runtimeProtocolVersion, minCompatibleRuntimeProtocolVersion } = + manifest() + const unversioned = { + schemaVersion, + runtimeProtocolVersion, + minCompatibleRuntimeProtocolVersion + } + expect(evaluate({ manifest: unversioned })).toEqual(tooOld(0)) + }) + + it('opens a page at the floor and one above it', () => { + expect(evaluate({ manifest: manifest({ pageVersion: 1 }) })).toEqual({ + kind: 'ok', + manifestChecked: true + }) + expect(evaluate({ manifest: manifest({ pageVersion: 7 }) })).toEqual({ + kind: 'ok', + manifestChecked: true + }) + }) +}) diff --git a/mobile/src/transport/mobile-web-bundle-compat.ts b/mobile/src/transport/mobile-web-bundle-compat.ts index b331540df66..d039816be14 100644 --- a/mobile/src/transport/mobile-web-bundle-compat.ts +++ b/mobile/src/transport/mobile-web-bundle-compat.ts @@ -1,6 +1,10 @@ import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' import type { HostStatusReply } from './host-status-reply-schema' +/** The oldest page this shell serves: the `MOBILE_WEB_PAGE_VERSION` of the page built beside it. An + * older desktop is walled rather than served, so the shell keeps no path for an older page. */ +export const MOBILE_WEB_PAGE_VERSION_FLOOR = 1 + /** The manifest schemas this app shell can mount. Widening it is a shell release, so the list is * stated here rather than read off the contract's current version: the contract names the schema * the desktop writes, which is exactly the number this shell may not recognise. */ @@ -24,6 +28,8 @@ export type MobileWebBundleCompatManifest = { schemaVersion: number runtimeProtocolVersion: number minCompatibleRuntimeProtocolVersion: number + /** Absent from a desktop older than the field, which reads as 0: below every floor. */ + pageVersion?: number } export type MobileWebBundleCompatVerdict = @@ -42,6 +48,14 @@ export type MobileWebBundleCompatVerdict = hostProtocolVersion: number requiredHostProtocolVersion: number } + /** The host serves a page older than this shell's floor. */ + | { + kind: 'blocked' + reason: 'bundle-incompatible' + side: 'desktop' + pageVersion: number + requiredPageVersion: number + } /** The bundle is older than the host expects; the caller refetches. */ | { kind: 'blocked' @@ -90,6 +104,16 @@ export function evaluateMobileWebBundleCompat(input: { schemaVersion: manifest.schemaVersion } } + const pageVersion = manifest.pageVersion ?? 0 + if (pageVersion < MOBILE_WEB_PAGE_VERSION_FLOOR) { + return { + kind: 'blocked', + reason: 'bundle-incompatible', + side: 'desktop', + pageVersion, + requiredPageVersion: MOBILE_WEB_PAGE_VERSION_FLOOR + } + } const hostProtocolVersion = input.hostStatus.protocolVersion ?? 0 if (hostProtocolVersion < manifest.minCompatibleRuntimeProtocolVersion) { return { diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts index ca00eca7951..57f5618763a 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts @@ -55,6 +55,7 @@ function manifestReply(overrides: Record = {}) { desktopVersion: '1.4.200', minCompatibleRuntimeProtocolVersion: 2, runtimeProtocolVersion: 2, + pageVersion: 1, entrypoint: 'index.html', totalBytes: 12, assets: [asset()], @@ -234,6 +235,16 @@ describe('mobile web bundle manifest reply reader', () => { expect(verdict).toEqual({ kind: 'ok', manifestChecked: true }) }) + it('reads the page version the floor compares, and one a desktop older than it never wrote', () => { + expect(MobileWebBundleManifestReplySchema.parse(manifestReply()).manifest.pageVersion).toBe(1) + expect(readManifest(manifestReply({ pageVersion: undefined })).compatible).toBe(true) + for (const pageVersion of [-1, 1.5, 'one', null]) { + expect(readManifest(manifestReply({ pageVersion })).compatible, String(pageVersion)).toBe( + false + ) + } + }) + it('refuses a manifest with no protocol window, which only a host without the capability sends', () => { expect(readManifest(manifestReply({ runtimeProtocolVersion: undefined })).compatible).toBe( false diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts index ea68e7dbd14..be9b7bf40ad 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.ts @@ -72,6 +72,7 @@ export const MobileWebBundleManifestReadSchema = z buildId: z.string().regex(SHA256_PATTERN), minCompatibleRuntimeProtocolVersion: z.number().int().nonnegative(), runtimeProtocolVersion: z.number().int().nonnegative(), + pageVersion: z.number().int().nonnegative().optional(), entrypoint: MobileWebBundleAssetPathSchema, totalBytes: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES), assets: z.array(assetSchema).min(1).max(MOBILE_WEB_BUNDLE_MAX_ASSETS), diff --git a/mobile/src/transport/mobile-web-page-version-floor.test.ts b/mobile/src/transport/mobile-web-page-version-floor.test.ts new file mode 100644 index 00000000000..284e136e7cc --- /dev/null +++ b/mobile/src/transport/mobile-web-page-version-floor.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { MOBILE_WEB_PAGE_VERSION } from '../../../src/shared/mobile-web-bundle/manifest-contract' +import { MOBILE_WEB_PAGE_VERSION_FLOOR } from './mobile-web-bundle-compat' + +describe("the shell's page floor", () => { + it('never requires a page newer than the one built beside this shell', () => { + expect(MOBILE_WEB_PAGE_VERSION_FLOOR).toBeLessThanOrEqual(MOBILE_WEB_PAGE_VERSION) + }) +}) diff --git a/mobile/src/transport/rpc-client-stream-registry.test.ts b/mobile/src/transport/rpc-client-stream-registry.test.ts index 51b22f9f291..6afcd8a7ef3 100644 --- a/mobile/src/transport/rpc-client-stream-registry.test.ts +++ b/mobile/src/transport/rpc-client-stream-registry.test.ts @@ -79,6 +79,20 @@ describe('RpcClientStreamRegistry', () => { ]) }) + it('ends one transcript stream on dispose and leaves a sibling on the same socket (U-03)', () => { + const { registry, sent } = createRegistry() + const disposeFirst = registry.subscribe('agentSession.subscribe', { sessionId: 's1' }, () => {}) + registry.subscribe('agentSession.subscribe', { sessionId: 's2' }, () => {}) + const [first] = sent + + disposeFirst() + expect(sent.at(-1)).toMatchObject({ + method: 'agentSession.unsubscribe', + params: { sessionId: 's1', subscriptionId: first!.id } + }) + expect(sent.filter((request) => request.method === 'agentSession.unsubscribe')).toHaveLength(1) + }) + it('names the terminal request it sent when unsubscribing, and keeps the slot for older hosts', () => { const { registry, sent } = createRegistry() const dispose = registry.subscribe( diff --git a/mobile/src/transport/rpc-client-stream-registry.ts b/mobile/src/transport/rpc-client-stream-registry.ts index b78ae29a31b..ebfdb741997 100644 --- a/mobile/src/transport/rpc-client-stream-registry.ts +++ b/mobile/src/transport/rpc-client-stream-registry.ts @@ -216,7 +216,7 @@ export class RpcClientStreamRegistry { this.sendRpc('terminal.unsubscribe', { ...params, requestId: id }) } } else { - const unsubscribe = buildStreamUnsubscribe(stream?.method, stream?.params) + const unsubscribe = buildStreamUnsubscribe(stream?.method, stream?.params, id) if (unsubscribe) { this.sendRpc(unsubscribe.method, unsubscribe.params) } diff --git a/mobile/src/transport/rpc-client-terminal-subscription.ts b/mobile/src/transport/rpc-client-terminal-subscription.ts index 405f7f1d9e0..2bed2ab0458 100644 --- a/mobile/src/transport/rpc-client-terminal-subscription.ts +++ b/mobile/src/transport/rpc-client-terminal-subscription.ts @@ -53,6 +53,14 @@ export function buildStreamUnsubscribe( } : null } + if (method === 'agentSession.subscribe') { + const sessionId = (params as { sessionId?: unknown }).sessionId + // The host keys each transcript stream by its frame id; without it every stream of the + // session on this socket would end. + return typeof sessionId === 'string' && requestId + ? { method: 'agentSession.unsubscribe', params: { sessionId, subscriptionId: requestId } } + : null + } if (method === 'nativeChat.subscribe') { const subscriptionId = (params as { subscriptionId?: unknown }).subscriptionId if (typeof subscriptionId === 'string') { diff --git a/mobile/web-entry/index.tsx b/mobile/web-entry/index.tsx index 2c346e0dbf2..57936f04252 100644 --- a/mobile/web-entry/index.tsx +++ b/mobile/web-entry/index.tsx @@ -20,6 +20,7 @@ import { PageFaultBoundary } from '../src/mobile-web-shell/bridge/page-fault-bou import { publishPageHostProfile } from '../src/mobile-web-shell/bridge/page-host-profile' import { publishExternalLinkOpener } from '../src/platform/external-link.web' import { publishHapticsNotifier } from '../src/platform/haptics.web' +import { publishShellKeyboardSource } from '../src/platform/keyboard-occlusion.web' // Named with its extension: this entry is the web build's and the provider it needs is the web // sibling's, which takes the page's client. The screens below still import `./client-context` // and reach the same module, because the builder resolves both specifiers to the same file. @@ -111,6 +112,11 @@ bootstrapShellPage({ // The same shape again, and for the same reason: every haptic on this page is played from a // plain function inside a row's press handler, which no provider wraps. publishHapticsNotifier((kind) => client.notifyHaptics(kind)) + // The shell's keyboard height, read by plain functions as well as hooks; 0 while it is closed. + publishShellKeyboardSource({ + read: () => client.getShellSession()?.keyboardInset ?? 0, + subscribe: client.onKeyboardInsetUpdate + }) // Scoped to the host `init` named: with none, no key is writable, which is the right answer // for a shell too old to say whose list this is. publishPageStorage( diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index afcc3c564d6..3dc3e661f6f 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -29,6 +29,10 @@ "file": "src/transport/host-device-token-store.web.ts", "reason": "expo-secure-store resolves to {} on web, and the bridge carries the RPC, so the page holds no device token." }, + { + "file": "src/platform/host-os.web.ts", + "reason": "Platform.OS is web inside the shell's page, but the keyboard the page lifts over is the phone's, reported in that OS's own terms: iOS counts the home indicator. Keyboard and inset arithmetic branches on the host OS, so this answers the phone's OS from the WebView's user agent, and the page computes what the native screen computes." + }, { "file": "app/_layout.web.tsx", "reason": "The page's route tree starts at app/h, so without a root layout expo-router mounts DefaultNavigator, an all-edges SafeAreaView that would pad every screen a second time. The native root owns pairing and push and cannot reach the page. This one adds no padding and feeds the insets the shell sent in init to react-native-safe-area-context, whose web provider reads env(safe-area-inset-*) as 0 in both WebViews." @@ -69,6 +73,10 @@ "file": "src/platform/keyboard-occlusion.web.ts", "reason": "react-native-web's Keyboard is a stub: addListener returns a subscription that never fires and isVisible() is always false, so a screen waiting for keyboardDidShow inside the page waits forever and the software keyboard covers whatever sits at the bottom of the document \u2014 the source-control commit bar, and the review note composer whose KeyboardAvoidingView is driven by those same events. The browser publishes the geometry a different way: the layout viewport keeps its size and visualViewport shrinks, so the occluded strip is innerHeight minus the visual viewport's height and offsetTop, tracked on its resize and scroll. A document with no visualViewport answers 0 rather than guessing." }, + { + "file": "src/platform/keyboard-persisting-taps.web.ts", + "reason": "react-native-web ignores keyboardShouldPersistTaps, and its Pressable is a focusable element that takes focus on mousedown. On Pixel_API_37 a tap on a session tab or a dock key moved focus off the live input and the IME closed, while natively a Pressable never takes focus and those two ScrollViews persist taps. This sibling gives the tab bar ('handled') and the accessory bar ('always') the same rule by keeping a focused text field's focus through the mousedown; the native module returns no ref." + }, { "file": "src/platform/live-input-composing-range.web.ts", "reason": "The terminal's Live input reads the change event's isComposing as the text system's marked-text range, and on the page that event is the DOM's. An Android WebView marks the keyboard's composing region, which Samsung and other Latin keyboards keep over every word, so the preedit mirror held each word unsent until the next keystroke or Enter. Native Android reports no range at all; this sibling answers the same in an Android WebView and passes the DOM's range through in any other browser. The native file passes the event's own through." diff --git a/package.json b/package.json index 1f1573f9577..feec79fee07 100644 --- a/package.json +++ b/package.json @@ -256,7 +256,7 @@ "cmdk": "^1.1.1", "diff": "8.0.4", "dompurify": "3.4.15", - "electron": "43.7.0", + "electron": "43.7.5", "electron-builder": "^26.15.3", "electron-builder-squirrel-windows": "^26.15.3", "electron-vite": "^5.0.0", @@ -266,7 +266,7 @@ "happy-dom": "^20.11.8", "html-to-image": "^1.11.13", "husky": "^9.1.7", - "i18next-cli": "1.65.0", + "i18next-cli": "1.74.1", "katex": "^0.16.45", "lint-staged": "^16.4.0", "lowlight": "^3.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c05265397b9..3d82edc51bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,7 +128,7 @@ importers: version: 0.3.251(@anthropic-ai/sdk@0.122.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.6.5))(zod@4.6.5) '@electron-toolkit/utils': specifier: ^4.0.0 - version: 4.0.0(electron@43.7.0(supports-color@7.2.0)) + version: 4.0.0(electron@43.7.5(supports-color@7.2.0)) '@floating-ui/dom': specifier: 1.8.0 version: 1.8.0 @@ -365,8 +365,8 @@ importers: specifier: 3.4.15 version: 3.4.15 electron: - specifier: 43.7.0 - version: 43.7.0(supports-color@7.2.0) + specifier: 43.7.5 + version: 43.7.5(supports-color@7.2.0) electron-builder: specifier: ^26.15.3 version: 26.15.3(electron-builder-squirrel-windows@26.15.3) @@ -395,8 +395,8 @@ importers: specifier: ^9.1.7 version: 9.1.7 i18next-cli: - specifier: 1.65.0 - version: 1.65.0(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(typescript@7.0.2) + specifier: 1.74.1 + version: 1.74.1(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) katex: specifier: ^0.16.45 version: 0.16.45 @@ -1099,8 +1099,12 @@ packages: resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/checkbox@5.2.1': - resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + '@inquirer/ansi@2.0.8': + resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.5': + resolution: {integrity: sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1117,6 +1121,15 @@ packages: '@types/node': optional: true + '@inquirer/confirm@6.3.2': + resolution: {integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/core@11.2.1': resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -1126,8 +1139,8 @@ packages: '@types/node': optional: true - '@inquirer/editor@5.2.2': - resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + '@inquirer/core@12.0.3': + resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1135,8 +1148,8 @@ packages: '@types/node': optional: true - '@inquirer/expand@5.1.1': - resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + '@inquirer/editor@5.3.3': + resolution: {integrity: sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1144,8 +1157,17 @@ packages: '@types/node': optional: true - '@inquirer/external-editor@3.0.3': - resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + '@inquirer/expand@5.1.5': + resolution: {integrity: sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.5': + resolution: {integrity: sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1157,8 +1179,12 @@ packages: resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/input@5.1.2': - resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + '@inquirer/figures@2.0.9': + resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.6': + resolution: {integrity: sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1166,8 +1192,8 @@ packages: '@types/node': optional: true - '@inquirer/number@4.1.1': - resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + '@inquirer/number@4.2.3': + resolution: {integrity: sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1175,8 +1201,8 @@ packages: '@types/node': optional: true - '@inquirer/password@5.1.1': - resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + '@inquirer/password@5.2.2': + resolution: {integrity: sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1184,8 +1210,8 @@ packages: '@types/node': optional: true - '@inquirer/prompts@8.5.2': - resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + '@inquirer/prompts@8.7.2': + resolution: {integrity: sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1193,8 +1219,8 @@ packages: '@types/node': optional: true - '@inquirer/rawlist@5.3.1': - resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + '@inquirer/rawlist@5.3.5': + resolution: {integrity: sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1202,8 +1228,8 @@ packages: '@types/node': optional: true - '@inquirer/search@4.2.1': - resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + '@inquirer/search@4.3.3': + resolution: {integrity: sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1211,8 +1237,8 @@ packages: '@types/node': optional: true - '@inquirer/select@5.2.1': - resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + '@inquirer/select@5.2.5': + resolution: {integrity: sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1229,6 +1255,15 @@ packages: '@types/node': optional: true + '@inquirer/type@4.1.1': + resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -1246,6 +1281,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -2923,18 +2961,36 @@ packages: cpu: [arm64] os: [darwin] + '@swc/core-darwin-arm64@1.16.2': + resolution: {integrity: sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + '@swc/core-darwin-x64@1.15.46': resolution: {integrity: sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==} engines: {node: '>=10'} cpu: [x64] os: [darwin] + '@swc/core-darwin-x64@1.16.2': + resolution: {integrity: sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + '@swc/core-linux-arm-gnueabihf@1.15.46': resolution: {integrity: sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==} engines: {node: '>=10'} cpu: [arm] os: [linux] + '@swc/core-linux-arm-gnueabihf@1.16.2': + resolution: {integrity: sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + '@swc/core-linux-arm64-gnu@1.15.46': resolution: {integrity: sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==} engines: {node: '>=10'} @@ -2942,6 +2998,13 @@ packages: os: [linux] libc: [glibc] + '@swc/core-linux-arm64-gnu@1.16.2': + resolution: {integrity: sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@swc/core-linux-arm64-musl@1.15.46': resolution: {integrity: sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==} engines: {node: '>=10'} @@ -2949,6 +3012,13 @@ packages: os: [linux] libc: [musl] + '@swc/core-linux-arm64-musl@1.16.2': + resolution: {integrity: sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@swc/core-linux-ppc64-gnu@1.15.46': resolution: {integrity: sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==} engines: {node: '>=10'} @@ -2956,6 +3026,13 @@ packages: os: [linux] libc: [glibc] + '@swc/core-linux-ppc64-gnu@1.16.2': + resolution: {integrity: sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@swc/core-linux-s390x-gnu@1.15.46': resolution: {integrity: sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==} engines: {node: '>=10'} @@ -2963,6 +3040,13 @@ packages: os: [linux] libc: [glibc] + '@swc/core-linux-s390x-gnu@1.16.2': + resolution: {integrity: sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@swc/core-linux-x64-gnu@1.15.46': resolution: {integrity: sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==} engines: {node: '>=10'} @@ -2970,6 +3054,13 @@ packages: os: [linux] libc: [glibc] + '@swc/core-linux-x64-gnu@1.16.2': + resolution: {integrity: sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@swc/core-linux-x64-musl@1.15.46': resolution: {integrity: sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==} engines: {node: '>=10'} @@ -2977,24 +3068,49 @@ packages: os: [linux] libc: [musl] + '@swc/core-linux-x64-musl@1.16.2': + resolution: {integrity: sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + '@swc/core-win32-arm64-msvc@1.15.46': resolution: {integrity: sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==} engines: {node: '>=10'} cpu: [arm64] os: [win32] + '@swc/core-win32-arm64-msvc@1.16.2': + resolution: {integrity: sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + '@swc/core-win32-ia32-msvc@1.15.46': resolution: {integrity: sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==} engines: {node: '>=10'} cpu: [ia32] os: [win32] + '@swc/core-win32-ia32-msvc@1.16.2': + resolution: {integrity: sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + '@swc/core-win32-x64-msvc@1.15.46': resolution: {integrity: sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==} engines: {node: '>=10'} cpu: [x64] os: [win32] + '@swc/core-win32-x64-msvc@1.16.2': + resolution: {integrity: sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + '@swc/core@1.15.46': resolution: {integrity: sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==} engines: {node: '>=10'} @@ -3004,12 +3120,24 @@ packages: '@swc/helpers': optional: true + '@swc/core@1.16.2': + resolution: {integrity: sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} '@swc/types@0.1.27': resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -4210,6 +4338,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@5.1.0: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} @@ -4638,8 +4770,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@43.7.0: - resolution: {integrity: sha512-m98FUehwnoo6q2D9Mr+n602Natb2CRFeKD81GhTdJlKh/Iyud0R1X8hChjpaMBLSsIX9r2ZEFnz0sdIALiO9ZQ==} + electron@43.7.5: + resolution: {integrity: sha512-AR+OLH/8QAwx6Lz6mhaDQhesbjC/Xh2WOiFSI/CDFU6FKX/qJdBg65bEUn+MLvbvIi/LlaRQeLljnUcKb1KS0A==} engines: {node: '>= 22.12.0'} hasBin: true @@ -4814,6 +4946,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + execa@10.0.1: + resolution: {integrity: sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==} + engines: {node: '>=22'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -5203,8 +5339,8 @@ packages: engines: {node: '>=18'} hasBin: true - i18next-cli@1.65.0: - resolution: {integrity: sha512-sak+2Ry4P7wtl7xMAZg2sWG2vup1lRHFBKA7h5IeEqFUog51QEgeYUhHxd2x85+MvS4BhVOZZs833clnd1WgYA==} + i18next-cli@1.74.1: + resolution: {integrity: sha512-8OEbo4jfn4r4/QwieyAvoLWV/rJW2OR6SG09I12jA6kffyhUNsyrH6OaA4bumLIWkdBaGySbiFm0wj1k19N/jA==} engines: {node: '>=22'} hasBin: true @@ -5220,6 +5356,14 @@ packages: typescript: optional: true + i18next@26.4.2: + resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -5258,8 +5402,8 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - inquirer@14.0.2: - resolution: {integrity: sha512-VsSx1JneSNp3ld1veMTLe+UDcUD8Tw2/jjOthhkX3/IX2q+xHhVELifeb/hsb1fBw31pabEPNUf/xUOyb+KZjA==} + inquirer@14.2.2: + resolution: {integrity: sha512-WGerZIIb7mTtaFFskkD/P0europ8xBWf1OXACMiugzKPSmVzoqhcPSuSUzbzMch/t3rf1ncBT3v/0WU73cLqJQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -5660,6 +5804,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -5925,6 +6072,10 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -6093,6 +6244,10 @@ packages: resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} engines: {node: '>=20'} + ora@9.4.1: + resolution: {integrity: sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==} + engines: {node: '>=20'} + orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} @@ -7339,6 +7494,11 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + which-command@0.1.0: + resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} + engines: {node: '>=22'} + hasBin: true + which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} @@ -7805,9 +7965,9 @@ snapshots: dependencies: '@types/node': 25.9.5 - '@electron-toolkit/utils@4.0.0(electron@43.7.0(supports-color@7.2.0))': + '@electron-toolkit/utils@4.0.0(electron@43.7.5(supports-color@7.2.0))': dependencies: - electron: 43.7.0(supports-color@7.2.0) + electron: 43.7.5(supports-color@7.2.0) '@electron/asar@3.4.1': dependencies: @@ -8078,14 +8238,17 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.2 - '@inquirer/ansi@2.0.7': {} + '@inquirer/ansi@2.0.7': + optional: true - '@inquirer/checkbox@5.2.1(@types/node@25.9.5)': + '@inquirer/ansi@2.0.8': {} + + '@inquirer/checkbox@5.2.5(@types/node@25.9.5)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@25.9.5) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@25.9.5) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 @@ -8095,6 +8258,14 @@ snapshots: '@inquirer/type': 4.0.7(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 + optional: true + + '@inquirer/confirm@6.3.2(@types/node@25.9.5)': + dependencies: + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@25.9.5) + optionalDependencies: + '@types/node': 25.9.5 '@inquirer/core@11.2.1(@types/node@25.9.5)': dependencies: @@ -8107,95 +8278,116 @@ snapshots: signal-exit: 4.1.0 optionalDependencies: '@types/node': 25.9.5 + optional: true - '@inquirer/editor@5.2.2(@types/node@25.9.5)': + '@inquirer/core@12.0.3(@types/node@25.9.5)': dependencies: - '@inquirer/core': 11.2.1(@types/node@25.9.5) - '@inquirer/external-editor': 3.0.3(@types/node@25.9.5) - '@inquirer/type': 4.0.7(@types/node@25.9.5) + '@inquirer/ansi': 2.0.8 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@25.9.5) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 optionalDependencies: '@types/node': 25.9.5 - '@inquirer/expand@5.1.1(@types/node@25.9.5)': + '@inquirer/editor@5.3.3(@types/node@25.9.5)': dependencies: - '@inquirer/core': 11.2.1(@types/node@25.9.5) - '@inquirer/type': 4.0.7(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/external-editor': 3.0.5(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 - '@inquirer/external-editor@3.0.3(@types/node@25.9.5)': + '@inquirer/expand@5.1.5(@types/node@25.9.5)': + dependencies: + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@25.9.5) + optionalDependencies: + '@types/node': 25.9.5 + + '@inquirer/external-editor@3.0.5(@types/node@25.9.5)': dependencies: chardet: 2.2.0 iconv-lite: 0.7.2 optionalDependencies: '@types/node': 25.9.5 - '@inquirer/figures@2.0.7': {} + '@inquirer/figures@2.0.7': + optional: true - '@inquirer/input@5.1.2(@types/node@25.9.5)': + '@inquirer/figures@2.0.9': {} + + '@inquirer/input@5.1.6(@types/node@25.9.5)': dependencies: - '@inquirer/core': 11.2.1(@types/node@25.9.5) - '@inquirer/type': 4.0.7(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 - '@inquirer/number@4.1.1(@types/node@25.9.5)': + '@inquirer/number@4.2.3(@types/node@25.9.5)': dependencies: - '@inquirer/core': 11.2.1(@types/node@25.9.5) - '@inquirer/type': 4.0.7(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 - '@inquirer/password@5.1.1(@types/node@25.9.5)': + '@inquirer/password@5.2.2(@types/node@25.9.5)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@25.9.5) - '@inquirer/type': 4.0.7(@types/node@25.9.5) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 - '@inquirer/prompts@8.5.2(@types/node@25.9.5)': + '@inquirer/prompts@8.7.2(@types/node@25.9.5)': dependencies: - '@inquirer/checkbox': 5.2.1(@types/node@25.9.5) - '@inquirer/confirm': 6.1.1(@types/node@25.9.5) - '@inquirer/editor': 5.2.2(@types/node@25.9.5) - '@inquirer/expand': 5.1.1(@types/node@25.9.5) - '@inquirer/input': 5.1.2(@types/node@25.9.5) - '@inquirer/number': 4.1.1(@types/node@25.9.5) - '@inquirer/password': 5.1.1(@types/node@25.9.5) - '@inquirer/rawlist': 5.3.1(@types/node@25.9.5) - '@inquirer/search': 4.2.1(@types/node@25.9.5) - '@inquirer/select': 5.2.1(@types/node@25.9.5) + '@inquirer/checkbox': 5.2.5(@types/node@25.9.5) + '@inquirer/confirm': 6.3.2(@types/node@25.9.5) + '@inquirer/editor': 5.3.3(@types/node@25.9.5) + '@inquirer/expand': 5.1.5(@types/node@25.9.5) + '@inquirer/input': 5.1.6(@types/node@25.9.5) + '@inquirer/number': 4.2.3(@types/node@25.9.5) + '@inquirer/password': 5.2.2(@types/node@25.9.5) + '@inquirer/rawlist': 5.3.5(@types/node@25.9.5) + '@inquirer/search': 4.3.3(@types/node@25.9.5) + '@inquirer/select': 5.2.5(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 - '@inquirer/rawlist@5.3.1(@types/node@25.9.5)': + '@inquirer/rawlist@5.3.5(@types/node@25.9.5)': dependencies: - '@inquirer/core': 11.2.1(@types/node@25.9.5) - '@inquirer/type': 4.0.7(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 - '@inquirer/search@4.2.1(@types/node@25.9.5)': + '@inquirer/search@4.3.3(@types/node@25.9.5)': dependencies: - '@inquirer/core': 11.2.1(@types/node@25.9.5) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 - '@inquirer/select@5.2.1(@types/node@25.9.5)': + '@inquirer/select@5.2.5(@types/node@25.9.5)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@25.9.5) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@25.9.5) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@25.9.5) optionalDependencies: '@types/node': 25.9.5 '@inquirer/type@4.0.7(@types/node@25.9.5)': optionalDependencies: '@types/node': 25.9.5 + optional: true + + '@inquirer/type@4.1.1(@types/node@25.9.5)': + optionalDependencies: + '@types/node': 25.9.5 '@isaacs/fs-minipass@4.0.1': dependencies: @@ -8215,6 +8407,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -9630,39 +9824,75 @@ snapshots: '@swc/core-darwin-arm64@1.15.46': optional: true + '@swc/core-darwin-arm64@1.16.2': + optional: true + '@swc/core-darwin-x64@1.15.46': optional: true + '@swc/core-darwin-x64@1.16.2': + optional: true + '@swc/core-linux-arm-gnueabihf@1.15.46': optional: true + '@swc/core-linux-arm-gnueabihf@1.16.2': + optional: true + '@swc/core-linux-arm64-gnu@1.15.46': optional: true + '@swc/core-linux-arm64-gnu@1.16.2': + optional: true + '@swc/core-linux-arm64-musl@1.15.46': optional: true + '@swc/core-linux-arm64-musl@1.16.2': + optional: true + '@swc/core-linux-ppc64-gnu@1.15.46': optional: true + '@swc/core-linux-ppc64-gnu@1.16.2': + optional: true + '@swc/core-linux-s390x-gnu@1.15.46': optional: true + '@swc/core-linux-s390x-gnu@1.16.2': + optional: true + '@swc/core-linux-x64-gnu@1.15.46': optional: true + '@swc/core-linux-x64-gnu@1.16.2': + optional: true + '@swc/core-linux-x64-musl@1.15.46': optional: true + '@swc/core-linux-x64-musl@1.16.2': + optional: true + '@swc/core-win32-arm64-msvc@1.15.46': optional: true + '@swc/core-win32-arm64-msvc@1.16.2': + optional: true + '@swc/core-win32-ia32-msvc@1.15.46': optional: true + '@swc/core-win32-ia32-msvc@1.16.2': + optional: true + '@swc/core-win32-x64-msvc@1.15.46': optional: true + '@swc/core-win32-x64-msvc@1.16.2': + optional: true + '@swc/core@1.15.46': dependencies: '@swc/counter': 0.1.3 @@ -9681,12 +9911,34 @@ snapshots: '@swc/core-win32-ia32-msvc': 1.15.46 '@swc/core-win32-x64-msvc': 1.15.46 + '@swc/core@1.16.2': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.16.2 + '@swc/core-darwin-x64': 1.16.2 + '@swc/core-linux-arm-gnueabihf': 1.16.2 + '@swc/core-linux-arm64-gnu': 1.16.2 + '@swc/core-linux-arm64-musl': 1.16.2 + '@swc/core-linux-ppc64-gnu': 1.16.2 + '@swc/core-linux-s390x-gnu': 1.16.2 + '@swc/core-linux-x64-gnu': 1.16.2 + '@swc/core-linux-x64-musl': 1.16.2 + '@swc/core-win32-arm64-msvc': 1.16.2 + '@swc/core-win32-ia32-msvc': 1.16.2 + '@swc/core-win32-x64-msvc': 1.16.2 + '@swc/counter@0.1.3': {} '@swc/types@0.1.27': dependencies: '@swc/counter': 0.1.3 + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -10907,6 +11159,8 @@ snapshots: commander@14.0.3: {} + commander@15.0.0: {} + commander@5.1.0: {} commander@7.2.0: {} @@ -11374,7 +11628,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@43.7.0(supports-color@7.2.0): + electron@43.7.5(supports-color@7.2.0): dependencies: '@electron-internal/extract-zip': 1.0.4 '@electron/get': 5.0.0(supports-color@7.2.0) @@ -11567,6 +11821,21 @@ snapshots: dependencies: eventsource-parser: 3.0.8 + execa@10.0.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + which-command: 0.1.0 + yoctocolors: 2.1.2 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -12108,28 +12377,28 @@ snapshots: husky@9.1.7: {} - i18next-cli@1.65.0(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(typescript@7.0.2): + i18next-cli@1.74.1(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): dependencies: '@croct/json5-parser': 0.2.2 - '@swc/core': 1.15.46 + '@swc/core': 1.16.2 chokidar: 5.0.0 - commander: 14.0.3 - execa: 9.6.1 + commander: 15.0.0 + execa: 10.0.1 glob: 13.0.6 - i18next: 26.3.1(typescript@7.0.2) + i18next: 26.4.2(typescript@7.0.2) i18next-resources-for-ts: 2.1.0 - inquirer: 14.0.2(@types/node@25.9.5) + inquirer: 14.2.2(@types/node@25.9.5) jiti: 2.7.0 jsonc-parser: 3.3.1 - magic-string: 0.30.21 - minimatch: 10.2.5 - ora: 9.4.0 - react: 19.2.8 - react-i18next: 17.0.15(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) + magic-string: 1.4.2 + minimatch: 10.2.6 + ora: 9.4.1 + react-i18next: 17.0.15(i18next@26.4.2(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) yaml: 2.9.1 transitivePeerDependencies: - '@swc/helpers' - '@types/node' + - react - react-dom - react-native - typescript @@ -12147,6 +12416,10 @@ snapshots: optionalDependencies: typescript: 7.0.2 + i18next@26.4.2(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -12177,12 +12450,12 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@14.0.2(@types/node@25.9.5): + inquirer@14.2.2(@types/node@25.9.5): dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@25.9.5) - '@inquirer/prompts': 8.5.2(@types/node@25.9.5) - '@inquirer/type': 4.0.7(@types/node@25.9.5) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/prompts': 8.7.2(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@25.9.5) mute-stream: 3.0.0 run-async: 4.0.6 optionalDependencies: @@ -12494,6 +12767,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + markdown-table@3.0.4: {} marked@14.0.0: {} @@ -12993,6 +13270,10 @@ snapshots: dependencies: brace-expansion: 5.0.9 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.18 @@ -13189,6 +13470,17 @@ snapshots: stdin-discarder: 0.3.2 string-width: 8.2.1 + ora@9.4.1: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.1 + orderedmap@2.1.1: {} outvariant@1.4.3: @@ -13712,6 +14004,17 @@ snapshots: react-dom: 19.2.8(react@19.2.8) typescript: 7.0.2 + react-i18next@17.0.15(i18next@26.4.2(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 4.0.1 + i18next: 26.4.2(typescript@7.0.2) + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + typescript: 7.0.2 + react-is@17.0.2: {} react-markdown@10.1.0(@types/react@19.2.17)(react@19.2.8): @@ -14648,6 +14951,8 @@ snapshots: whatwg-mimetype@3.0.0: {} + which-command@0.1.0: {} + which-module@2.0.1: {} which@2.0.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1cf67829478..e8a845e1975 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,7 +16,6 @@ minimumReleaseAge: 4320 minimumReleaseAgeExclude: - pdfjs-dist@6.3.289 - zod@4.5.4 - - electron@43.7.0 - '@shadcn/lint@0.1.0' shamefullyHoist: true diff --git a/src/cli/index-worktree-create-freebuff.test.ts b/src/cli/index-worktree-create-freebuff.test.ts new file mode 100644 index 00000000000..aab4b17c36e --- /dev/null +++ b/src/cli/index-worktree-create-freebuff.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' + +const { + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock +} = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'), + addEnvironmentFromPairingCodeMock: vi.fn(), + listEnvironmentsMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness.js') + return createRuntimeClientModuleMock({ + callMock, + runtimeClientConstructorMock, + serveOrcaAppMock, + getDefaultUserDataPathMock + }) +}) + +vi.mock('./runtime/environments', () => ({ + addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock, + listEnvironments: listEnvironmentsMock, + removeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) + +vi.mock('child_process', async () => { + const { createChildProcessModuleMock } = await import('./index-test-harness.js') + return createChildProcessModuleMock(spawnMock) +}) + +import { main } from './index' +import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' +import { useWorktreeAwarenessEnvironment } from './index-test-harness' + +describe('Freebuff worktree creation', () => { + useWorktreeAwarenessEnvironment({ + callMock, + serveOrcaAppMock, + getDefaultUserDataPathMock, + addEnvironmentFromPairingCodeMock, + listEnvironmentsMock, + spawnMock + }) + + it('passes Freebuff and its prompt to the runtime without activating the worktree', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]), + okFixture('req_create', { + worktree: buildWorktree('/tmp/repo/freebuff-task', 'freebuff-task', 'abc', 'repo-1') + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-1', + '--name', + 'freebuff-task', + '--agent', + 'freebuff', + '--prompt', + 'Review this workspace', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'worktree.create', + expect.objectContaining({ + startupAgent: 'freebuff', + startupPrompt: 'Review this workspace', + activate: false + }) + ) + }) +}) 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 625cc06f505..ee38da3a7fe 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.ts @@ -19,7 +19,7 @@ vi.mock('./local-agent-cli-presence', () => ({ detectLocalManagedAgentCliPresence: mocks.detect })) -vi.mock('../claude/claude-session-end-hook-capability', () => ({ +vi.mock('../claude/claude-hook-event-versions', () => ({ probeClaudeCliVersion: mocks.probeClaudeVersion })) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 7edfd94c7c8..fe7f18ccc32 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -5,7 +5,7 @@ import { } from '../../shared/managed-agent-hook-targets' import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' -import { probeClaudeCliVersion } from '../claude/claude-session-end-hook-capability' +import { probeClaudeCliVersion } from '../claude/claude-hook-event-versions' import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence' import { MANAGED_AGENT_HOOK_ASYNC_REMOVERS, diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 560233e8363..31513658035 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -1,3 +1,4 @@ +import { qoderHookService } from '../qoder/hook-service' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { HookInstallAgent } from '../../shared/telemetry-events' import { ampHookService } from '../amp/hook-service' @@ -8,6 +9,7 @@ import { commandCodeHookService } from '../command-code/hook-service' import { copilotHookService } from '../copilot/hook-service' import { cursorHookService } from '../cursor/hook-service' import { devinHookService } from '../devin/hook-service' +import { dshHookService } from '../dsh/hook-service' import { droidHookService } from '../droid/hook-service' import { geminiHookService } from '../gemini/hook-service' import { grokHookService } from '../grok/hook-service' @@ -43,6 +45,7 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['openclaude', () => openClaudeHookService.install()], ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], + ['qoder', () => qoderHookService.install()], ['antigravity', () => antigravityHookService.install()], ['amp', () => ampHookService.install()], ['cursor', () => cursorHookService.install()], @@ -54,7 +57,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['devin', () => devinHookService.install()], ['kimi', () => kimiHookService.install()], ['muse', () => museHookService.install()], - ['zcode', () => zcodeHookService.install()] + ['zcode', () => zcodeHookService.install()], + ['dsh', () => dshHookService.install()] ] // Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a @@ -68,6 +72,7 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['openclaude', () => openClaudeHookService.refreshManagedScripts()], ['codex', () => codexHookService.refreshManagedScripts()], ['gemini', () => geminiHookService.refreshManagedScripts()], + ['qoder', () => qoderHookService.refreshManagedScripts()], ['antigravity', () => antigravityHookService.refreshManagedScripts()], ['cursor', () => cursorHookService.refreshManagedScripts()], ['droid', () => droidHookService.refreshManagedScripts()], @@ -77,7 +82,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['devin', () => devinHookService.refreshManagedScripts()], ['kimi', () => kimiHookService.refreshManagedScripts()], ['muse', () => museHookService.refreshManagedScripts()], - ['zcode', () => zcodeHookService.refreshManagedScripts()] + ['zcode', () => zcodeHookService.refreshManagedScripts()], + ['dsh', () => dshHookService.refreshManagedScripts()] ] export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ @@ -85,6 +91,7 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['openclaude', () => openClaudeHookService.remove()], ['codex', () => codexHookService.remove()], ['gemini', () => geminiHookService.remove()], + ['qoder', () => qoderHookService.remove()], ['antigravity', () => antigravityHookService.remove()], ['amp', () => ampHookService.remove()], ['cursor', () => cursorHookService.remove()], @@ -96,7 +103,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['devin', () => devinHookService.remove()], ['kimi', () => kimiHookService.remove()], ['muse', () => museHookService.remove()], - ['zcode', () => zcodeHookService.remove()] + ['zcode', () => zcodeHookService.remove()], + ['dsh', () => dshHookService.remove()] ] export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [ @@ -108,6 +116,7 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['openclaude', () => openClaudeHookService.getStatus()], ['codex', () => codexHookService.getStatus()], ['gemini', () => geminiHookService.getStatus()], + ['qoder', () => qoderHookService.getStatus()], ['antigravity', () => antigravityHookService.getStatus()], ['amp', () => ampHookService.getStatus()], ['cursor', () => cursorHookService.getStatus()], @@ -119,5 +128,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['devin', () => devinHookService.getStatus()], ['kimi', () => kimiHookService.getStatus()], ['muse', () => museHookService.getStatus()], - ['zcode', () => zcodeHookService.getStatus()] + ['zcode', () => zcodeHookService.getStatus()], + ['dsh', () => dshHookService.getStatus()] ] diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index 774e9396c25..b3166a6c90e 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -22,6 +22,7 @@ import { import { getDevinManagedCommand, getDevinRemoteManagedCommand } from '../devin/hook-settings' import { getGrokManagedCommand } from '../grok/grok-hook-script' import { getMuseManagedCommand, getMuseRemoteManagedCommand } from '../muse/hook-settings' +import { getDshManagedCommand, getDshRemoteManagedCommand } from '../dsh/hook-settings' import { getZCodeManagedCommand, getZCodeRemoteManagedCommand } from '../zcode/hook-settings' import { wrapPosixHookCommand, @@ -81,6 +82,20 @@ const buildersByAgent = new Map([ remote: (path) => [getClaudeRemoteCommand(path)] } ], + [ + 'qoder', + { + local: (path) => [ + getManagedLifecycleHook(path, { + configDirName: '.qoder', + scriptBaseName: 'qoder-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }).command + ], + remote: (path) => [getClaudeRemoteCommand(path)] + } + ], [ 'codex', { @@ -151,6 +166,13 @@ const buildersByAgent = new Map([ remote: (path) => [getMuseRemoteManagedCommand(path)] } ], + [ + 'dsh', + { + local: (path) => [getDshManagedCommand(path)], + remote: (path) => [getDshRemoteManagedCommand(path)] + } + ], [ 'zcode', { @@ -198,10 +220,15 @@ describe('managed hook command contract', () => { expect(commands.length).toBeGreaterThan(0) for (const command of commands) { expect(command.length).toBeGreaterThan(0) - // Native Windows Codex evaluates PowerShell variables without Grok's dollar-byte scanner. + // Native PowerShell hooks evaluate these variables without Grok's dollar-byte scanner. const scannedCommand = - agent === 'codex' && platform === 'win32' && command.startsWith('if (Test-Path') - ? command.replaceAll('$LASTEXITCODE', '').replaceAll('$env:', '') + platform === 'win32' && + ((agent === 'codex' && command.startsWith('if (Test-Path')) || + (agent === 'qoder' && command.startsWith('$scriptPath = Join-Path'))) + ? command + .replaceAll('$LASTEXITCODE', '') + .replaceAll('$env:', '') + .replaceAll('$scriptPath', '') : command expect(findBareHookCommandVariables(scannedCommand), command).toEqual([]) } diff --git a/src/main/agent-hooks/managed-hook-detection-commands.ts b/src/main/agent-hooks/managed-hook-detection-commands.ts index f9507160e27..0d3ee8280d5 100644 --- a/src/main/agent-hooks/managed-hook-detection-commands.ts +++ b/src/main/agent-hooks/managed-hook-detection-commands.ts @@ -7,7 +7,7 @@ import { MANAGED_AGENT_HOOK_TARGETS } from '../../shared/managed-agent-hook-targ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands' -import { parseClaudeCliVersion } from '../claude/claude-session-end-hook-capability' +import { parseClaudeCliVersion } from '../claude/claude-hook-event-versions' export type ManagedHookDetectionSettings = Partial< Pick diff --git a/src/main/agent-hooks/managed-hooks-json-events.ts b/src/main/agent-hooks/managed-hooks-json-events.ts new file mode 100644 index 00000000000..bff2bbf6942 --- /dev/null +++ b/src/main/agent-hooks/managed-hooks-json-events.ts @@ -0,0 +1,38 @@ +import { isPlainObject } from './installer-utils' + +/** + * Which of `events` an Orca-owned `{ hooks: { : [{ hooks: [{ command }] }] } }` file + * still registers under a managed command. + * + * Shared by every agent whose managed hooks file Orca generates wholesale (Muse, DSH), so + * "installed", "partial" and "not_installed" cannot drift between them. + * + * Why every lookup is guarded: the file is on disk and hand-editable, so any node can be + * null, a scalar, or the wrong container. A malformed node reads as "event absent" — status + * calculation must report a broken install, never throw on it. + */ +export function readManagedHookEventsFromJson( + parsed: unknown, + events: readonly string[], + isManagedCommand: (command: string | undefined) => boolean +): Set { + const hooks = isPlainObject(parsed) && isPlainObject(parsed.hooks) ? parsed.hooks : {} + return new Set( + events.filter((event) => + asArray(hooks[event]).some((definition) => + asArray(isPlainObject(definition) ? definition.hooks : null).some((hook) => + isManagedCommand(readCommand(hook)) + ) + ) + ) + ) +} + +function asArray(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value : [] +} + +function readCommand(hook: unknown): string | undefined { + const command = isPlainObject(hook) ? hook.command : undefined + return typeof command === 'string' ? command : undefined +} diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 148d5f719d7..f3cc23937b4 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -1,3 +1,4 @@ +import { qoderHookService } from '../qoder/hook-service' import { describe, expect, it, vi } from 'vitest' import { parse as parseJsonc } from 'jsonc-parser' import type { SFTPWrapper } from 'ssh2' @@ -685,6 +686,18 @@ describe('remote hook service installers', () => { expect(fs.modes.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toBe(0o755) }) + it('installs Qoder on the execution host with its own event endpoint', async () => { + const { sftp, fs } = createFakeSftp() + const result = await qoderHookService.installRemote(sftp, '/home/dev/') + expect(result.state).toBe('installed') + expect(result.configPath).toBe('/home/dev/.qoder/settings.json') + const settings = JSON.parse(fs.files.get(result.configPath) ?? '{}') + expect(settings.hooks.SessionEnd).toHaveLength(1) + expect(settings.hooks.Notification).toHaveLength(1) + expect(settings.hooks.TeammateIdle).toBeUndefined() + expect(fs.files.get('/home/dev/.orca/agent-hooks/qoder-hook.sh')).toContain('/hook/qoder') + }) + it('installs Droid and Copilot when running the aggregate remote installer (issue #7253)', async () => { const { sftp } = createFakeSftp() const results = await installRemoteManagedAgentHooks(sftp, '/home/dev', { diff --git a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts index f145ebc9be8..f9ff9c232e0 100644 --- a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts +++ b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts @@ -19,8 +19,10 @@ import { geminiHookService } from '../gemini/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' +import { dshHookService } from '../dsh/hook-service' import { museHookService } from '../muse/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +import { qoderHookService } from '../qoder/hook-service' import { zcodeHookService } from '../zcode/hook-service' import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' import { REMOTE_MANAGED_HOOK_INSTALLER_AGENTS } from './remote-managed-hook-installers' @@ -50,7 +52,9 @@ describe('remote hook service registry coverage', () => { ['devin', devinHookService], ['kimi', kimiHookService], ['muse', museHookService], - ['zcode', zcodeHookService] + ['qoder', qoderHookService], + ['zcode', zcodeHookService], + ['dsh', dshHookService] ]) // Guard against a service silently missing from the map above as new agents land. diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 1f42bdd68a6..272e330be11 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -1,3 +1,4 @@ +import { qoderHookService } from '../qoder/hook-service' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types' import { ampHookService } from '../amp/hook-service' @@ -13,6 +14,7 @@ import { droidHookService } from '../droid/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' +import { dshHookService } from '../dsh/hook-service' import { museHookService } from '../muse/hook-service' import { zcodeHookService } from '../zcode/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' @@ -61,6 +63,7 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ }) ], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], + ['qoder', (sftp, remoteHome) => qoderHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], ['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)], ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], @@ -76,7 +79,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)], ['muse', (sftp, remoteHome) => museHookService.installRemote(sftp, remoteHome)], - ['zcode', (sftp, remoteHome) => zcodeHookService.installRemote(sftp, remoteHome)] + ['zcode', (sftp, remoteHome) => zcodeHookService.installRemote(sftp, remoteHome)], + ['dsh', (sftp, remoteHome) => dshHookService.installRemote(sftp, remoteHome)] ] /** Agents wired into the remote (SSH) hook installer. Exported so an invariant diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index d38f6c836c7..a405199a6e7 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -22,6 +22,7 @@ afterEach(() => vi.restoreAllMocks()) * classifier names no boundary for that source. That is not the same as "can never revive": * mimo-code's boundary is an explicit-prompt MessagePart, which the gate handles separately. */ const NEW_TURN_EVENT: Record = { + qoder: 'SessionStart', claude: 'SessionStart', kimi: 'UserPromptSubmit', codex: 'SessionStart', @@ -42,7 +43,8 @@ const NEW_TURN_EVENT: Record = { 'mimo-code': null, 'command-code': null, muse: 'UserPromptSubmit', - zcode: 'SessionStart' + zcode: 'SessionStart', + dsh: 'SessionStart' } function reviveRetiredPane(source: unknown, hookEventName: string): boolean { diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 090a65f7650..1d9bae4f45d 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -21,8 +21,8 @@ * for a workspace it can resolve. That is why the decision is in two halves rather than one. * * What genuinely differs per surface is only how a surface is *built* — an orchestration worker's - * session takes a dispatch hold and a mailbox that a plain launch must not take — so that is - * injected as a factory instead of branched on here. + * session takes a redrive subscription and a mailbox that a plain launch must not take — so that + * is injected as a factory instead of branched on here. */ import { parsePaneKey } from '../../shared/stable-pane-id' diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 14d2d4b2797..867eac40ab6 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -8,8 +8,8 @@ import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent' import type { TuiAgent } from '../../shared/tui-agent' /** How a surface is built once the executor has decided which one. Injected because an - * orchestration worker's session carries a dispatch hold and a mailbox a plain launch must not - * take, while the decision and ordering above it are identical. */ + * orchestration worker's session carries a redrive subscription and a mailbox a plain launch + * must not take, while the decision and ordering above it are identical. */ export type AgentLaunchSurfaceFactory = { createStructuredSession(args: { worktreeId: string diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 30e2ba487f3..d84fc68a468 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -6,7 +6,7 @@ import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' import { runExclusivelyForCodexTrustConfig } from './codex/codex-trust-config-mutation-queue' -export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' | 'antigravity' +export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' /** * Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or diff --git a/src/main/ai-vault-search/session-search-deleted-sources.test.ts b/src/main/ai-vault-search/session-search-deleted-sources.test.ts index 5fd4e5cd630..39fbda9bb14 100644 --- a/src/main/ai-vault-search/session-search-deleted-sources.test.ts +++ b/src/main/ai-vault-search/session-search-deleted-sources.test.ts @@ -264,6 +264,31 @@ it('keeps a synthetic row when this pass did not enumerate its container', async ).resolves.toMatchObject({ retired: [], unverifiable: [row] }) }) +it('keeps a ZCode SQLite row when its database read failed during discovery', async () => { + const db = join(harness.root, 'db.sqlite') + await writeFile(db, '') + const row = `${db}#zcode-session` + + const result = await retire([row], { roots: [] }) + expect(result.retired).toEqual([]) + expect(result.unverifiable).toEqual([row]) + expect(removed).toEqual([]) +}) + +it('retires a ZCode SQLite row only after a successful full database enumeration', async () => { + const db = join(harness.root, 'db.sqlite') + await writeFile(db, '') + const kept = `${db}#session-1` + const deleted = `${db}#session-2` + + const result = await retire([kept, deleted], { + roots: [], + enumeratedContainers: new Map([[db, new Set(['session-1'])]]) + }) + expect(result.retired).toEqual([deleted]) + expect(result.unverifiable).toEqual([]) +}) + it('retires a synthetic row when the container it came from is gone', async () => { const db = join(harness.root, 'opencode.db') await writeFile(db, '') diff --git a/src/main/ai-vault-search/session-search-synthetic-sources.ts b/src/main/ai-vault-search/session-search-synthetic-sources.ts index 86222b9c5fe..59541e7924f 100644 --- a/src/main/ai-vault-search/session-search-synthetic-sources.ts +++ b/src/main/ai-vault-search/session-search-synthetic-sources.ts @@ -4,15 +4,14 @@ import type { SessionFileCandidate } from '../ai-vault/session-scanner-types' /** * A row whose path names a container and an entry inside it rather than a file - * of its own. OpenCode's SQLite sessions are the one shape today - * (`#`), which is why this reads through that source's - * own splitter rather than reinventing the encoding. + * of its own. OpenCode and ZCode SQLite sessions share the same encoding, + * with distinct database names validated by the source splitter. */ export type SessionSearchSyntheticSource = { container: string; id: string } export function splitSyntheticSessionSource(path: string): SessionSearchSyntheticSource | null { - const openCode = splitOpenCodeSqliteCandidate(path) - return openCode ? { container: openCode.dbPath, id: openCode.sessionId } : null + const sqlite = splitOpenCodeSqliteCandidate(path) ?? splitOpenCodeSqliteCandidate(path, 'zcode') + return sqlite ? { container: sqlite.dbPath, id: sqlite.sessionId } : null } /** diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts index cf1a5bb2bed..c0aa1b8a219 100644 --- a/src/main/ai-vault/session-first-user-prompt-read.ts +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -9,7 +9,8 @@ import { parseAgentSessionFile } from './session-scanner-agent-parser' import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' import { parseOpenCodeSqliteSessionViaWorker, - parseOpenCode2SqliteSessionViaWorker + parseOpenCode2SqliteSessionViaWorker, + parseZcodeSqliteSessionViaWorker } from './session-scanner-opencode-sqlite-worker-spawn' import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' import type { FileWithMtime } from './session-scanner-types' @@ -76,12 +77,14 @@ async function parseSessionForFullFirstUserPrompt(args: { codexHome: string | null }): Promise { // Full capture belongs inside the reader, including the guest reader for WSL. - if (args.agent === 'opencode' || args.agent === 'opencode2') { + if (args.agent === 'opencode' || args.agent === 'opencode2' || args.agent === 'zcode') { const parse = args.agent === 'opencode2' ? parseOpenCode2SqliteSessionViaWorker - : parseOpenCodeSqliteSessionViaWorker - const fromSynthetic = splitOpenCodeSqliteCandidate(args.filePath) + : args.agent === 'zcode' + ? parseZcodeSqliteSessionViaWorker + : parseOpenCodeSqliteSessionViaWorker + const fromSynthetic = splitOpenCodeSqliteCandidate(args.filePath, args.agent) if (fromSynthetic) { return parse({ fullFirstUserPrompt: true, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index c95dd8d7eac..80a56bf2519 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -12,8 +12,10 @@ import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite- import { captureOpenCodeSqliteSessionViaWorker, captureOpenCode2SqliteSessionViaWorker, + captureZcodeSqliteSessionViaWorker, parseOpenCode2SqliteSessionViaWorker, - parseOpenCodeSqliteSessionViaWorker + parseOpenCodeSqliteSessionViaWorker, + parseZcodeSqliteSessionViaWorker } from './session-scanner-opencode-sqlite-worker-spawn' import { parseClaudeSessionFile } from './session-scanner-primary-parsers' import { parseGeminiSessionFile } from './session-scanner-gemini-parsers' @@ -38,7 +40,7 @@ async function readOpenCodeSqliteCandidate( platform: NodeJS.Platform, messages?: TranscriptMessageSink, signal?: AbortSignal, - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' ): Promise { throwIfSignalAborted(signal) const request = { ...sqliteCandidate, platform, signal } @@ -46,7 +48,9 @@ async function readOpenCodeSqliteCandidate( const parse = agent === 'opencode2' ? parseOpenCode2SqliteSessionViaWorker - : parseOpenCodeSqliteSessionViaWorker + : agent === 'zcode' + ? parseZcodeSqliteSessionViaWorker + : parseOpenCodeSqliteSessionViaWorker const session = await parse(request) throwIfSignalAborted(signal) return session @@ -54,7 +58,9 @@ async function readOpenCodeSqliteCandidate( const capture = agent === 'opencode2' ? captureOpenCode2SqliteSessionViaWorker - : captureOpenCodeSqliteSessionViaWorker + : agent === 'zcode' + ? captureZcodeSqliteSessionViaWorker + : captureOpenCodeSqliteSessionViaWorker const result = await capture(request) for (const message of result.messages) { throwIfSignalAborted(signal) @@ -119,6 +125,12 @@ export async function parseAgentSessionFile( } return null } + case 'zcode': { + const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path, 'zcode') + return sqliteCandidate + ? readOpenCodeSqliteCandidate(sqliteCandidate, platform, messages, signal, 'zcode') + : null + } case 'grok': return parseGrokSessionFile(candidate.file, platform, messages) case 'hermes': diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 79271cbf39e..4d001d00422 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -219,6 +219,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), museSessionsDir: join(root, 'muse-sessions'), + zcodeDbPath: join(root, 'zcode-db.sqlite'), platform: 'darwin' }) diff --git a/src/main/ai-vault/session-scanner-every-agent-fixture.ts b/src/main/ai-vault/session-scanner-every-agent-fixture.ts index 5ae04c11b57..23caa8e348d 100644 --- a/src/main/ai-vault/session-scanner-every-agent-fixture.ts +++ b/src/main/ai-vault/session-scanner-every-agent-fixture.ts @@ -5,6 +5,7 @@ import { } from './session-scanner-test-fixtures' import { writeDocumentAgentFixtures } from './session-scanner-document-agent-fixtures' import { writeLogAgentFixtures } from './session-scanner-log-agent-fixtures' +import { writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture' // Why this is shared rather than inline in one test: it is the only place that // writes one transcript in every supported agent's own format. A scan test and @@ -34,5 +35,13 @@ export async function writeEveryAgentVault(root: string): Promise OPENCODE_CAPTURE_RECORD_LIMIT) { throw new Error( - `OpenCode session ${sessionId} holds more than ${OPENCODE_CAPTURE_RECORD_LIMIT} text parts; its transcript was not read.` + `${agentName} session ${sessionId} holds more than ${OPENCODE_CAPTURE_RECORD_LIMIT} text parts; its transcript was not read.` ) } @@ -178,7 +184,7 @@ export function readOpenCodeSessionMessages( captured += message.text.length if (captured > OPENCODE_CAPTURE_TEXT_LIMIT) { throw new Error( - `OpenCode session ${sessionId} decodes to more than ${OPENCODE_CAPTURE_TEXT_LIMIT} characters; its transcript was not read.` + `${agentName} session ${sessionId} decodes to more than ${OPENCODE_CAPTURE_TEXT_LIMIT} characters; its transcript was not read.` ) } messages.push(message) @@ -237,13 +243,17 @@ export async function captureOpenCodeSqliteSession(args: { dbPath: string sessionId: string platform: NodeJS.Platform + agent?: 'opencode' | 'zcode' }): Promise { return readOpenCodeDatabase({ dbPath: args.dbPath, read: (db) => { const session = readOpenCodeSqliteSession({ db, ...args }) // No session row is no transcript: the id names nothing in this database. - return { session, messages: session ? readOpenCodeSessionMessages(db, args.sessionId) : [] } + return { + session, + messages: session ? readOpenCodeSessionMessages(db, args.sessionId, args.agent) : [] + } } }) } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index ff51677d27f..e2fbcb22512 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -17,6 +17,9 @@ vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => { return { listOpenCode2SqliteSessionsViaWorker: listOpenCode2SqliteSessions, listOpenCodeSqliteSessionsViaWorker: listOpenCodeSqliteSessions, + // ZCode reuses the OpenCode SQLite lister; the worker only varies the agent it stamps. + listZcodeSqliteSessionsViaWorker: (args: Parameters[0]) => + listOpenCodeSqliteSessions({ ...args, agent: 'zcode' }), parseOpenCodeSqliteSessionViaWorker: parseOpenCodeSqliteSession } }) @@ -54,6 +57,7 @@ function isolatedScanRoots(root: string) { droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), museSessionsDir: join(root, 'muse-sessions'), + zcodeDbPath: join(root, 'zcode-db.sqlite'), ompSessionsDir: join(root, 'omp-sessions'), primeAgentSessionsDir: join(root, 'prime-agent-sessions') } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts index 7632aa070c0..e483f9482e1 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts @@ -30,7 +30,8 @@ export async function handleOpenCodeSqliteRequest( : await listOpenCodeSqliteSessions({ dbPaths: request.dbPaths, limit: request.limit ?? Infinity, - issues + issues, + agent: request.agent === 'zcode' ? 'zcode' : 'opencode' }) return { id: request.id, ok: true, value: { candidates, issues } } } @@ -38,7 +39,10 @@ export async function handleOpenCodeSqliteRequest( const capture = request.agent === 'opencode2' ? await captureOpenCode2SqliteSession(request) - : await captureOpenCodeSqliteSession(request) + : await captureOpenCodeSqliteSession({ + ...request, + agent: request.agent === 'zcode' ? 'zcode' : 'opencode' + }) return { id: request.id, ok: true, value: capture } } const parse = async () => @@ -51,7 +55,8 @@ export async function handleOpenCodeSqliteRequest( : await parseOpenCodeSqliteSession({ dbPath: request.dbPath, sessionId: request.sessionId, - platform: request.platform + platform: request.platform, + agent: request.agent === 'zcode' ? 'zcode' : 'opencode' }) const session = request.fullFirstUserPrompt ? await withFullFirstUserPromptCapture(parse) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index 93f9daab98c..7e739ba2238 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -1,4 +1,4 @@ -import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { buildOpenCodeSqliteCandidatePath, splitOpenCodeSqliteCandidate @@ -54,13 +54,17 @@ function readSessionRows(db: SyncDatabase, limit: number): SessionRow[] { return (limited ? statement.all(limit) : statement.all()) as SessionRow[] } -function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { +function rowToCandidate( + row: SessionRow, + dbPath: string, + agent: 'opencode' | 'zcode' +): SessionFileCandidate { const mtimeMs = typeof row.time_updated === 'number' && row.time_updated > 0 ? row.time_updated : row.time_created return { - agent: 'opencode' as AiVaultAgent, + agent, file: { path: buildOpenCodeSqliteCandidatePath(dbPath, row.id), mtimeMs, @@ -70,10 +74,13 @@ function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { } } -function dedupeAndSortSqliteCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] { +function dedupeAndSortSqliteCandidates( + candidates: SessionFileCandidate[], + agent: 'opencode' | 'zcode' +): SessionFileCandidate[] { const candidatesBySessionId = new Map() for (const candidate of candidates) { - const parsed = splitOpenCodeSqliteCandidate(candidate.file.path) + const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, agent) if (!parsed) { continue } @@ -102,8 +109,10 @@ export async function listOpenCodeSqliteSessions(args: { dbPaths: readonly string[] limit: number issues: AiVaultScanIssue[] + agent?: 'opencode' | 'zcode' }): Promise { const candidates: SessionFileCandidate[] = [] + const agent = args.agent ?? 'opencode' for (const dbPath of args.dbPaths) { try { const rows = readOpenCodeDatabase({ @@ -111,12 +120,12 @@ export async function listOpenCodeSqliteSessions(args: { read: (db) => readSessionRows(db, args.limit) }) for (const row of rows) { - candidates.push(rowToCandidate(row, dbPath)) + candidates.push(rowToCandidate(row, dbPath, agent)) } } catch (err) { // A whole DB failed, not one transcript: kinded so the panel says so. - args.issues.push(openCodeDatabaseScanIssue(dbPath, err)) + args.issues.push(openCodeDatabaseScanIssue(dbPath, err, agent)) } } - return dedupeAndSortSqliteCandidates(candidates) + return dedupeAndSortSqliteCandidates(candidates, agent) } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts index 43e32da2ff5..32f807cb1d9 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts @@ -82,8 +82,13 @@ export function readOpenCodeDatabase(args: { * @param error - The thrown value from the read. * @returns A kinded scan issue with actionable copy. */ -export function openCodeDatabaseScanIssue(dbPath: string, error: unknown): AiVaultScanIssue { +export function openCodeDatabaseScanIssue( + dbPath: string, + error: unknown, + agent: 'opencode' | 'zcode' = 'opencode' +): AiVaultScanIssue { const name = basename(dbPath) + const label = agent === 'zcode' ? 'ZCode' : 'OpenCode' // `fileMustExist` already proved the database file is there before SQLite ran, // so this needs no fs probe of its own — an extra sync stat on a 9p share is // exactly the hang the WSL transcript gate exists to prevent. @@ -96,18 +101,18 @@ export function openCodeDatabaseScanIssue(dbPath: string, error: unknown): AiVau const overWslShare = isWslUncPath(dbPath) const detail = kind === 'contended' && !overWslShare - ? `OpenCode is writing to ${name} right now, so its history was skipped. It is read again on the next refresh.` + ? `${label} is writing to ${name} right now, so its history was skipped. It is read again on the next refresh.` : kind === 'unreadable' - ? `OpenCode history in ${name} could not be read: ${errorMessage(error)}` - : unreadableShareDetail(dbPath, name) - return { agent: 'opencode', kind: 'scope', path: dbPath, message: detail } + ? `${label} history in ${name} could not be read: ${errorMessage(error)}` + : unreadableShareDetail(dbPath, name, label) + return { agent, kind: 'scope', path: dbPath, message: detail } } -function unreadableShareDetail(dbPath: string, name: string): string { +function unreadableShareDetail(dbPath: string, name: string, label: string): string { // A known limitation, not a failure the user can act on: nothing they do on // the Windows side makes the share hand out SQLite's locks. Deliberately not // "flush the write-ahead log" either — checkpointing changes nothing here. return isWslUncPath(dbPath) - ? "OpenCode sessions inside WSL can't be searched from Windows yet." - : `OpenCode history in ${name} could not be read. Its write-ahead log cannot be opened read-only on this filesystem. Exit OpenCode cleanly to flush the log.` + ? `${label} sessions inside WSL can't be searched from Windows yet.` + : `${label} history in ${name} could not be read. Its write-ahead log cannot be opened read-only on this filesystem. Exit ${label} cleanly to flush the log.` } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts index 7f0a61a674a..77c0e464154 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts @@ -26,7 +26,8 @@ export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: stri * @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise. */ export function splitOpenCodeSqliteCandidate( - candidatePath: string + candidatePath: string, + agent: 'opencode' | 'opencode2' | 'zcode' = 'opencode' ): { dbPath: string; sessionId: string } | null { const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR) if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) { @@ -39,7 +40,11 @@ export function splitOpenCodeSqliteCandidate( } // Why: OpenCode DB files are named opencode*.db; reject anything else so we // never misroute a real filesystem path that happens to contain '#'. - if (!/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath))) { + const validName = + agent === 'zcode' + ? basename(dbPath).toLowerCase() === 'db.sqlite' + : /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath)) + if (!validName) { return null } return { dbPath, sessionId } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts index 9c197b1ecea..e53d89eeeab 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts @@ -16,7 +16,7 @@ function validRequest(value: unknown): value is OpenCodeSqliteWorkerRequest { !('id' in value) || !Number.isSafeInteger(value.id) || !('kind' in value) || - ('agent' in value && value.agent !== 'opencode2') || + ('agent' in value && value.agent !== 'opencode2' && value.agent !== 'zcode') || ('timeoutMs' in value && (typeof value.timeoutMs !== 'number' || !Number.isFinite(value.timeoutMs) || diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-rows.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-rows.ts new file mode 100644 index 00000000000..6d72cc4f64e --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-rows.ts @@ -0,0 +1,48 @@ +import type { SqliteRow } from '../sqlite/sqlite-statement' +import type { SQLOutputValue } from 'node:sqlite' + +type SessionRow = { + id: string + title: string | null + directory: string | null + time_created: number + time_updated: number + model_json: string | null + agent: string | null + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number + cost: number + message_count: number +} + +// Why guards and not casts: `SqliteStatement` hands back `Record`, +// so the column types are only a claim about the query until something checks them. A row +// from a schema that drifted, or from a database another tool wrote, reaches here too. +export function readPartDataRow(row: SqliteRow): { part_data: string } | null { + return typeof row.part_data === 'string' ? { part_data: row.part_data } : null +} + +export function readSessionRow(row: SqliteRow | undefined): SessionRow | null { + if (!row || typeof row.id !== 'string') { + return null + } + const text = (value: SQLOutputValue): string | null => (typeof value === 'string' ? value : null) + const count = (value: SQLOutputValue): number => (typeof value === 'number' ? value : 0) + return { + id: row.id, + title: text(row.title), + directory: text(row.directory), + time_created: count(row.time_created), + time_updated: count(row.time_updated), + model_json: text(row.model_json), + agent: text(row.agent), + tokens_input: count(row.tokens_input), + tokens_output: count(row.tokens_output), + tokens_reasoning: count(row.tokens_reasoning), + tokens_cache_read: count(row.tokens_cache_read), + cost: count(row.cost), + message_count: count(row.message_count) + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index 498bb9a65f5..c5f96c030e3 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -98,7 +98,7 @@ export class OpenCodeSqliteWorkerClient { dbPaths: readonly string[] limit: number issues: AiVaultScanIssue[] - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' signal?: AbortSignal }): Promise { if (args.dbPaths.length === 0) { @@ -161,7 +161,7 @@ export class OpenCodeSqliteWorkerClient { dbPath: string sessionId: string platform: NodeJS.Platform - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' signal?: AbortSignal }): Promise { try { @@ -201,7 +201,7 @@ export class OpenCodeSqliteWorkerClient { dbPath: string sessionId: string platform: NodeJS.Platform - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' signal?: AbortSignal }): Promise { try { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts index d44bd62c11a..9c8e42a5c7f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts @@ -12,7 +12,7 @@ export type OpenCodeSqliteListRequest = { dbPaths: readonly string[] limit: number | null /** When 'opencode2', lists from the v2 channel-scoped DB schema (session_v2). */ - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' } export type OpenCodeSqliteParseRequest = { @@ -23,7 +23,7 @@ export type OpenCodeSqliteParseRequest = { sessionId: string platform: NodeJS.Platform /** When 'opencode2', parses from the v2 channel-scoped DB schema (session_v2). */ - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' } // Same arguments as `parse`, different answer: the session plus every message @@ -36,7 +36,7 @@ export type OpenCodeSqliteCaptureRequest = { sessionId: string platform: NodeJS.Platform /** When 'opencode2', captures from the v2 channel-scoped schema. */ - agent?: 'opencode2' + agent?: 'opencode2' | 'zcode' } export type OpenCodeSqliteWorkerRequest = ( diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index 1b3b1faf7ce..68b1bacf698 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -81,6 +81,15 @@ export function listOpenCode2SqliteSessionsViaWorker(args: { return listForHost({ ...args, agent: 'opencode2' }) } +export function listZcodeSqliteSessionsViaWorker(args: { + dbPaths: readonly string[] + limit: number + issues: AiVaultScanIssue[] + signal?: AbortSignal +}): Promise { + return listForHost({ ...args, agent: 'zcode' }) +} + /** * Parse one OpenCode SQLite session through the shared worker client. * @param args.dbPath - Absolute path to the opencode.db file. @@ -108,6 +117,16 @@ export function parseOpenCode2SqliteSessionViaWorker(args: { return parseForHost({ ...args, agent: 'opencode2' }) } +export function parseZcodeSqliteSessionViaWorker(args: { + fullFirstUserPrompt?: boolean + dbPath: string + sessionId: string + platform: NodeJS.Platform + signal?: AbortSignal +}): Promise { + return parseForHost({ ...args, agent: 'zcode' }) +} + /** * Read one OpenCode SQLite session and its whole transcript through the shared * worker client. @@ -134,6 +153,15 @@ export function captureOpenCode2SqliteSessionViaWorker(args: { return captureForHost({ ...args, agent: 'opencode2' }) } +export function captureZcodeSqliteSessionViaWorker(args: { + dbPath: string + sessionId: string + platform: NodeJS.Platform + signal?: AbortSignal +}): Promise { + return captureForHost({ ...args, agent: 'zcode' }) +} + async function listForHost( args: Parameters[0] ): Promise { @@ -160,7 +188,7 @@ async function listForHost( const client = await openCodeWslClient(distro, first, args.signal) const result = await client.list({ ...args, dbPaths: [...paths.keys()], issues }) return result.flatMap((candidate) => { - const parsed = splitOpenCodeSqliteCandidate(candidate.file.path) + const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, args.agent) const original = parsed && paths.get(parsed.dbPath) return parsed && original ? [ diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index 291a4ce0e88..4e3d8bc74b8 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -10,11 +10,13 @@ import { shouldCaptureFullFirstUserPrompt } from './session-scanner-first-user-prompt' import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open' +import { readPartDataRow, readSessionRow } from './session-scanner-opencode-sqlite-rows' import { canCountOpenCodeMessages, canReadOpenCodeMessageParts } from './session-scanner-opencode-sqlite-schema' import { normalizeTitleText } from './session-scanner-values' +import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility' import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' @@ -34,22 +36,6 @@ const OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW = 100 // Bounds a pathological single message; a real typed prompt is a handful of parts. const FIRST_USER_PROMPT_PART_LIMIT = 512 -type SessionRow = { - id: string - title: string | null - directory: string | null - time_created: number - time_updated: number - model_json: string | null - agent: string | null - tokens_input: number - tokens_output: number - tokens_reasoning: number - tokens_cache_read: number - cost: number - message_count: number -} - type PreviewRow = { role: string | null part_data: string @@ -74,10 +60,11 @@ function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0' } -function buildSessionQuery(db: SyncDatabase): string { +function buildSessionQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): string { const messageCountSubquery = canCountOpenCodeMessages(db) ? `(SELECT COUNT(*) FROM message m WHERE m.session_id = s.id + ${zcodeVisibleMessageFilter(agent)} AND json_extract(m.data, '$.role') IN ('user','assistant'))` : '0' return `SELECT s.id, @@ -150,7 +137,11 @@ export function extractPartText(partData: string): string | null { } } -function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null { +function readFirstUserPromptFromOpenCodeDb( + db: SyncDatabase, + sessionId: string, + agent: 'opencode' | 'zcode' +): string | null { if (!canReadOpenCodeMessageParts(db)) { return null } @@ -168,6 +159,7 @@ function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): FROM message m JOIN part fp ON fp.message_id = m.id WHERE m.session_id = ? + ${zcodeVisibleMessageFilter(agent)} AND json_extract(m.data, '$.role') = 'user' AND json_extract(fp.data, '$.type') = 'text' ORDER BY m.time_created ASC, m.id ASC @@ -177,7 +169,9 @@ function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): ORDER BY p.time_created ASC, p.rowid ASC LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}` ) - .all(sessionId) as { part_data: string }[] + .all(sessionId) + .map(readPartDataRow) + .filter((row): row is { part_data: string } => row !== null) const parts: string[] = [] for (const row of rows) { @@ -195,7 +189,7 @@ function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): } } -function buildPreviewQuery(db: SyncDatabase): string | null { +function buildPreviewQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): string | null { if (!canReadOpenCodeMessageParts(db)) { return null } @@ -206,6 +200,7 @@ function buildPreviewQuery(db: SyncDatabase): string | null { json_extract(m.data, '$.summary.body') AS summary_body FROM (SELECT id, data FROM message WHERE session_id = ? + ${zcodeVisibleMessageFilter(agent, 'data')} ORDER BY time_created DESC, id DESC LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m JOIN part p ON p.message_id = m.id @@ -231,6 +226,7 @@ export async function parseOpenCodeSqliteSession(args: { dbPath: string sessionId: string platform: NodeJS.Platform + agent?: 'opencode' | 'zcode' }): Promise { return readOpenCodeDatabase({ dbPath: args.dbPath, @@ -245,12 +241,14 @@ export function readOpenCodeSqliteSession(args: { dbPath: string sessionId: string platform: NodeJS.Platform + agent?: 'opencode' | 'zcode' }): AiVaultSession | null { const { db, dbPath, sessionId, platform } = args if (!canReadOpenCodeSessions(db)) { return null } - const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined + const agent = args.agent ?? 'opencode' + const row = readSessionRow(db.prepare(buildSessionQuery(db, agent)).get(sessionId)) if (!row || row.id !== sessionId) { return null } @@ -262,7 +260,7 @@ export function readOpenCodeSqliteSession(args: { // Why: discovery uses a synthetic db#session path only for parser routing. // The UI's log open/reveal actions need a real filesystem path. const accumulator = createAccumulator({ - agent: 'opencode', + agent: args.agent ?? 'opencode', file: { path: dbPath, mtimeMs, @@ -279,7 +277,7 @@ export function readOpenCodeSqliteSession(args: { updateTimeline(accumulator, row.time_created) updateTimeline(accumulator, row.time_updated) - const previewSql = buildPreviewQuery(db) + const previewSql = buildPreviewQuery(db, agent) if (previewSql) { // Why: SQL already dropped anything older than the newest-N window, so the // accumulator never shifts and cannot detect the truncation itself. Ask for @@ -322,7 +320,7 @@ export function readOpenCodeSqliteSession(args: { // Why: list preview only joins the newest messages. On-demand copy needs the // session's earliest real user text part, not a later turn still in the window. if (shouldCaptureFullFirstUserPrompt()) { - accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) + accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId, agent) } return finalizeSession(accumulator, platform) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index a9dff003948..4610c198cb3 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -81,6 +81,7 @@ function resumableStateFactoryFor( case 'muse': case 'opencode': case 'opencode2': + case 'zcode': case 'rovo': return null } diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index 5bb53a605ba..62b2e0562a8 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -2,6 +2,7 @@ import { delimiter } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import { discoverFiles } from './session-scanner-discovery' import { opencodeDiscoveries } from './session-scanner-opencode-sources' +import { zcodeDiscoveries } from './session-scanner-zcode-sources' import { antigravityDiscoveries } from './session-scanner-antigravity-sources' import { AI_VAULT_AGENT_SOURCES, type AiVaultAgentSource } from './session-scanner-agent-sources' import { normalizedWslHomeDirs } from './session-scanner-roots' @@ -28,6 +29,7 @@ export async function discoverAiVaultSessionSources(args: { // SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy) // and the SQLite scanner (1.17.x); dedup by sessionId happens inside. ...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), + ...zcodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...antigravityDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...Object.entries(AI_VAULT_AGENT_SOURCES).flatMap(([agent, source]) => source diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 7146d237bf7..43d03d340dd 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -63,6 +63,7 @@ export function isolatedScanRoots(root: string) { // Why: prevent the SQLite scanner from picking up the real // ~/.local/share/opencode/opencode.db during tests. opencodeDbPaths: [] as readonly string[], + zcodeDbPath: join(root, 'zcode', 'db.sqlite'), grokSessionsDir: join(root, 'grok-sessions'), devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 75a10283562..099b3593431 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -29,6 +29,8 @@ export type AiVaultScanOptions = { // Why: OpenCode 1.17.x stores sessions in SQLite; tests inject a temp DB // here so they don't depend on the real ~/.local/share/opencode. opencodeDbPaths?: readonly string[] + /** Test override for the ZCode CLI's OpenCode-shaped SQLite database. */ + zcodeDbPath?: string grokSessionsDir?: string devinTranscriptsDir?: string hermesSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner-zcode-sources.test.ts b/src/main/ai-vault/session-scanner-zcode-sources.test.ts new file mode 100644 index 00000000000..4ed6667342f --- /dev/null +++ b/src/main/ai-vault/session-scanner-zcode-sources.test.ts @@ -0,0 +1,41 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { wslGatedAccess } from '../native-chat/wsl-transcript-fs-access' +import { zcodeDiscoveries } from './session-scanner-zcode-sources' + +vi.mock('../native-chat/wsl-transcript-fs-access', () => ({ wslGatedAccess: vi.fn() })) + +const access = vi.mocked(wslGatedAccess) + +afterEach(() => access.mockReset()) + +it.each(['EACCES', 'EPERM', 'EIO'])( + 'reports a ZCode database %s error instead of treating it as absent', + async (code) => { + access.mockRejectedValueOnce( + Object.assign(new Error(`Cannot read database: ${code}`), { code }) + ) + const issues: AiVaultScanIssue[] = [] + + const [discovery] = await Promise.all( + zcodeDiscoveries({ zcodeDbPath: '/example/db.sqlite' }, [], 10, issues) + ) + + expect(discovery?.files).toEqual([]) + expect(issues).toEqual([ + { agent: 'zcode', path: '/example/db.sqlite', message: `Cannot read database: ${code}` } + ]) + } +) + +it('silently skips a ZCode database that does not exist', async () => { + access.mockRejectedValueOnce(Object.assign(new Error('No such file'), { code: 'ENOENT' })) + const issues: AiVaultScanIssue[] = [] + + const [discovery] = await Promise.all( + zcodeDiscoveries({ zcodeDbPath: '/example/db.sqlite' }, [], 10, issues) + ) + + expect(discovery?.files).toEqual([]) + expect(issues).toEqual([]) +}) diff --git a/src/main/ai-vault/session-scanner-zcode-sources.ts b/src/main/ai-vault/session-scanner-zcode-sources.ts new file mode 100644 index 00000000000..68b013712e9 --- /dev/null +++ b/src/main/ai-vault/session-scanner-zcode-sources.ts @@ -0,0 +1,55 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { throwIfSignalAborted } from '../../shared/abort-signal-reason' +import { wslGatedAccess } from '../native-chat/wsl-transcript-fs-access' +import { listZcodeSqliteSessionsViaWorker } from './session-scanner-opencode-sqlite-worker-spawn' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +// ZCode's CLI stores its OpenCode-shaped history in a separate database. +// Keep discovery separate so an OpenCode install cannot absorb ZCode rows. +export function zcodeDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const paths = options.zcodeDbPath + ? [options.zcodeDbPath] + : [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite')) + return paths.map(async (dbPath) => ({ + agent: 'zcode' as const, + rootDir: dbPath, + files: (await zcodeDatabaseExists(dbPath, issues, options.signal)) + ? ( + await listZcodeSqliteSessionsViaWorker({ + dbPaths: [dbPath], + limit, + issues, + signal: options.signal + }) + ).map((candidate) => candidate.file) + : [] + })) +} + +async function zcodeDatabaseExists( + dbPath: string, + issues: AiVaultScanIssue[], + signal?: AbortSignal +): Promise { + try { + return await wslGatedAccess(dbPath, 'scan', signal) + } catch (error) { + throwIfSignalAborted(signal) + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false + } + issues.push({ + agent: 'zcode', + path: dbPath, + message: error instanceof Error ? error.message : String(error) + }) + return false + } +} diff --git a/src/main/ai-vault/session-scanner-zcode-visibility.ts b/src/main/ai-vault/session-scanner-zcode-visibility.ts new file mode 100644 index 00000000000..06c0446b0e0 --- /dev/null +++ b/src/main/ai-vault/session-scanner-zcode-visibility.ts @@ -0,0 +1,9 @@ +/** Keep ZCode's hidden transcript messages out of every AI Vault read path. */ +export function zcodeVisibleMessageFilter( + agent: 'opencode' | 'zcode', + column: 'm.data' | 'data' = 'm.data' +): string { + return agent === 'zcode' + ? `AND COALESCE(json_extract(${column}, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'` + : '' +} diff --git a/src/main/ai-vault/session-scanner-zcode.test.ts b/src/main/ai-vault/session-scanner-zcode.test.ts new file mode 100644 index 00000000000..4783b213450 --- /dev/null +++ b/src/main/ai-vault/session-scanner-zcode.test.ts @@ -0,0 +1,188 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { handleOpenCodeSqliteRequest } from './session-scanner-opencode-sqlite-dispatch' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import { writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture' +import SyncDatabase from '../sqlite/sync-database' + +// Why readers and not casts: the worker dispatch returns `value: unknown` on purpose — it +// crosses a process boundary. Narrowing here keeps the repo's no-assertions rule and fails +// the test with a readable message if the payload shape ever changes. +function readCandidates(value: unknown): { agent: string; path: string }[] { + if (typeof value !== 'object' || value === null || !('candidates' in value)) { + throw new Error('expected a listing payload with candidates') + } + const { candidates } = value + if (!Array.isArray(candidates)) { + throw new Error('expected candidates to be an array') + } + return candidates.map((candidate) => { + const agent = readStringField(candidate, 'agent') + const file = isRecord(candidate) ? candidate['file'] : null + return { agent, path: readStringField(file, 'path') } + }) +} + +function readMessageTexts(value: unknown): { text: string }[] { + if (typeof value !== 'object' || value === null || !('messages' in value)) { + throw new Error('expected a capture payload with messages') + } + const { messages } = value + if (!Array.isArray(messages)) { + throw new Error('expected messages to be an array') + } + return messages.map((message) => ({ text: readStringField(message, 'text') })) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function readStringField(value: unknown, field: string): string { + const read = isRecord(value) ? value[field] : null + if (typeof read !== 'string') { + throw new Error(`expected a string \`${field}\``) + } + return read +} + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories) { + rmSync(directory, { recursive: true, force: true }) + } + directories.length = 0 +}) + +describe('ZCode AI Vault SQLite worker', () => { + it('lists, parses, and captures ZCode sessions without labelling them OpenCode', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-ai-vault-')) + directories.push(directory) + const dbPath = join(directory, 'db.sqlite') + writeOpenCodeSqliteDatabase(dbPath, [ + { + id: 'zcode-session', + directory: '/repo', + title: 'ZCode task', + turns: [ + { role: 'user', parts: ['Fix the import'] }, + { role: 'assistant', parts: ['Import fixed'] } + ] + } + ]) + + const listed = await handleOpenCodeSqliteRequest({ + id: 1, + kind: 'list', + agent: 'zcode', + dbPaths: [dbPath], + limit: 10 + }) + expect(listed.ok).toBe(true) + if (!listed.ok) { + return + } + const candidates = readCandidates(listed.value) + expect(candidates).toHaveLength(1) + expect(candidates[0]?.agent).toBe('zcode') + expect(splitOpenCodeSqliteCandidate(candidates[0]!.path, 'zcode')).toEqual({ + dbPath, + sessionId: 'zcode-session' + }) + expect(splitOpenCodeSqliteCandidate(candidates[0]!.path)).toBeNull() + + const parsed = await handleOpenCodeSqliteRequest({ + id: 2, + kind: 'parse', + agent: 'zcode', + dbPath, + sessionId: 'zcode-session', + platform: 'darwin' + }) + expect(parsed).toMatchObject({ + ok: true, + value: { + agent: 'zcode', + title: 'ZCode task', + resumeCommand: "cd '/repo' && zcode --resume 'zcode-session'" + } + }) + + const captured = await handleOpenCodeSqliteRequest({ + id: 3, + kind: 'capture', + agent: 'zcode', + dbPath, + sessionId: 'zcode-session', + platform: 'darwin' + }) + expect(captured).toMatchObject({ ok: true, value: { session: { agent: 'zcode' } } }) + if (!captured.ok) { + return + } + expect(readMessageTexts(captured.value)).toEqual( + expect.arrayContaining([expect.objectContaining({ text: 'Fix the import' })]) + ) + }) + + it('excludes ZCode hidden transcript messages from preview and search', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-ai-vault-')) + directories.push(directory) + const dbPath = join(directory, 'db.sqlite') + writeOpenCodeSqliteDatabase(dbPath, [ + { + id: 'visible-session', + turns: [ + { role: 'user', parts: ['internal reminder'] }, + { role: 'user', parts: ['visible request'] }, + { role: 'assistant', parts: ['visible response'] } + ] + } + ]) + const db = new SyncDatabase(dbPath) + try { + db.prepare( + "UPDATE message SET data = json_set(data, '$.semantics.transcriptVisibility', 'hidden') WHERE id LIKE '%-msg-0-%'" + ).run() + } finally { + db.close() + } + + const parsed = await handleOpenCodeSqliteRequest({ + id: 4, + kind: 'parse', + agent: 'zcode', + dbPath, + sessionId: 'visible-session', + platform: 'darwin', + fullFirstUserPrompt: true + }) + expect(parsed).toMatchObject({ + ok: true, + value: { + messageCount: 2, + firstUserPrompt: 'visible request' + } + }) + if (!parsed.ok) { + return + } + expect(JSON.stringify(parsed.value)).not.toContain('internal reminder') + + const captured = await handleOpenCodeSqliteRequest({ + id: 5, + kind: 'capture', + agent: 'zcode', + dbPath, + sessionId: 'visible-session', + platform: 'darwin' + }) + expect(captured.ok).toBe(true) + if (!captured.ok) { + return + } + expect(JSON.stringify(captured.value)).not.toContain('internal reminder') + }) +}) diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index b8963ce80b8..5bbe8d09751 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -23,9 +23,15 @@ vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => { listOpenCodeSqliteSessionsViaWorker: ( args: Parameters[0] ) => v1List.listOpenCodeSqliteSessions(args), + listZcodeSqliteSessionsViaWorker: ( + args: Parameters[0] + ) => v1List.listOpenCodeSqliteSessions({ ...args, agent: 'zcode' }), parseOpenCodeSqliteSessionViaWorker: ( args: Parameters[0] ) => v1Parse.parseOpenCodeSqliteSession(args), + parseZcodeSqliteSessionViaWorker: ( + args: Parameters[0] + ) => v1Parse.parseOpenCodeSqliteSession({ ...args, agent: 'zcode' }), listOpenCode2SqliteSessionsViaWorker: ( args: Parameters[0] ) => v2List.listOpenCode2SqliteSessions(args), @@ -400,7 +406,7 @@ describe('scanAiVaultSessions', () => { await writeEveryAgentVault(root) await writeMuseScannerFixture(roots.museSessionsDir) - const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 25 }) expect(result.issues).toEqual([]) expect(new Set(result.sessions.map((session) => session.agent))).toEqual( @@ -428,6 +434,7 @@ describe('scanAiVaultSessions', () => { expect(commandByAgent.get('opencode2')).toBe( "cd '/tmp/opencode2' && opencode2 --standalone --session 'opencode2-session'" ) + expect(commandByAgent.get('zcode')).toBe("cd '/tmp/zcode' && zcode --resume 'zcode-session'") expect(commandByAgent.get('grok')).toBe("cd '/tmp/grok' && grok --resume 'grok-session'") expect(commandByAgent.get('hermes')).toBe( "cd '/tmp/hermes' && hermes --resume 'hermes-session'" diff --git a/src/main/ai-vault/session-transcript-every-agent-capture.test.ts b/src/main/ai-vault/session-transcript-every-agent-capture.test.ts index 0d135cdb7e8..20b28fdb49b 100644 --- a/src/main/ai-vault/session-transcript-every-agent-capture.test.ts +++ b/src/main/ai-vault/session-transcript-every-agent-capture.test.ts @@ -20,18 +20,27 @@ vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => { listOpenCode2SqliteSessionsViaWorker: ( args: Parameters[0] ) => list2.listOpenCode2SqliteSessions(args), + listZcodeSqliteSessionsViaWorker: ( + args: Parameters[0] + ) => list.listOpenCodeSqliteSessions({ ...args, agent: 'zcode' }), parseOpenCodeSqliteSessionViaWorker: ( args: Parameters[0] ) => parse.parseOpenCodeSqliteSession(args), parseOpenCode2SqliteSessionViaWorker: ( args: Parameters[0] ) => parse2.parseOpenCode2SqliteSession(args), + parseZcodeSqliteSessionViaWorker: ( + args: Parameters[0] + ) => parse.parseOpenCodeSqliteSession({ ...args, agent: 'zcode' }), captureOpenCodeSqliteSessionViaWorker: ( args: Parameters[0] ) => capture.captureOpenCodeSqliteSession(args), captureOpenCode2SqliteSessionViaWorker: ( args: Parameters[0] - ) => capture2.captureOpenCode2SqliteSession(args) + ) => capture2.captureOpenCode2SqliteSession(args), + captureZcodeSqliteSessionViaWorker: ( + args: Parameters[0] + ) => capture.captureOpenCodeSqliteSession({ ...args, agent: 'zcode' }) } }) import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../shared/ai-vault-types' diff --git a/src/main/claude/__fixtures__/claude-hook-event-enums.json b/src/main/claude/__fixtures__/claude-hook-event-enums.json new file mode 100644 index 00000000000..f4bedfc31df --- /dev/null +++ b/src/main/claude/__fixtures__/claude-hook-event-enums.json @@ -0,0 +1,960 @@ +{ + "source": "Read from cli.js of each @anthropic-ai/claude-code npm release. enums: the hook event enum; hooks keys outside it make 1.0.23-2.1.100 discard the whole user settings file. topLevelSettings: the settings schema's top-level keys, and whether it is strict, i.e. discards the whole file over any other key.", + "previousPublishedVersion": { + "1.0.23": "1.0.22", + "1.0.31": "1.0.30", + "1.0.41": "1.0.40", + "1.0.53": "1.0.52", + "1.0.62": "1.0.61", + "1.0.64": "1.0.63", + "1.0.85": "1.0.84", + "2.0.43": "2.0.42", + "2.0.45": "2.0.44", + "2.0.56": "2.0.55", + "2.1.33": "2.1.32", + "2.1.76": "2.1.75", + "2.1.78": "2.1.77" + }, + "topLevelSettings": { + "1.0.48": { + "strict": false, + "keys": [ + "apiKeyHelper", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod" + ] + }, + "1.0.49": { + "strict": true, + "keys": [ + "apiKeyHelper", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod" + ] + }, + "1.0.50": { + "strict": true, + "keys": [ + "apiKeyHelper", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod" + ] + }, + "1.0.51": { + "strict": true, + "keys": [ + "apiKeyHelper", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod" + ] + }, + "1.0.52": { + "strict": true, + "keys": [ + "apiKeyHelper", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod" + ] + }, + "1.0.53": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.54": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.58": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.59": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.60": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.61": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.62": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper" + ] + }, + "1.0.63": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper", + "outputMode" + ] + }, + "1.0.64": { + "strict": true, + "keys": [ + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "statusLine", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper", + "outputMode", + "feedbackSurveyState" + ] + }, + "1.0.65": { + "strict": true, + "keys": [ + "$schema", + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "statusLine", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper", + "outputMode", + "feedbackSurveyState" + ] + }, + "1.0.66": { + "strict": true, + "keys": [ + "$schema", + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "statusLine", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper", + "outputMode" + ] + }, + "1.0.67": { + "strict": false, + "keys": [ + "$schema", + "apiKeyHelper", + "awsCredentialExport", + "awsAuthRefresh", + "cleanupPeriodDays", + "env", + "includeCoAuthoredBy", + "permissions", + "model", + "enableAllProjectMcpServers", + "enabledMcpjsonServers", + "disabledMcpjsonServers", + "hooks", + "statusLine", + "learnMode", + "forceLoginMethod", + "otelHeadersHelper", + "outputMode" + ] + } + }, + "enums": { + "1.0.22": [ + "PreTask", + "PreBash", + "PreGlob", + "PreGrep", + "PreRead", + "PreEdit", + "PreMultiEdit", + "PreWrite", + "PreNotebookRead", + "PreNotebookEdit", + "PreWebFetch", + "PreWebSearch" + ], + "1.0.23": ["PreToolUse", "PostToolUse", "Notification"], + "1.0.30": ["PreToolUse", "PostToolUse", "Notification"], + "1.0.31": ["PreToolUse", "PostToolUse", "Notification", "Stop"], + "1.0.40": ["PreToolUse", "PostToolUse", "Notification", "Stop"], + "1.0.41": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop"], + "1.0.48": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop", "PreCompact"], + "1.0.49": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop", "PreCompact"], + "1.0.50": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop", "PreCompact"], + "1.0.51": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop", "PreCompact"], + "1.0.52": ["PreToolUse", "PostToolUse", "Notification", "Stop", "SubagentStop", "PreCompact"], + "1.0.53": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.54": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.58": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.59": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.60": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.61": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.62": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.63": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.64": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.65": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.66": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.67": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.81": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.84": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "Stop", + "SubagentStop", + "PreCompact" + ], + "1.0.85": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStop", + "PreCompact" + ], + "2.0.42": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStop", + "PreCompact" + ], + "2.0.43": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact" + ], + "2.0.44": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact" + ], + "2.0.45": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.52": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.54": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.55": [ + "PreToolUse", + "PostToolUse", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.56": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.60": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.0.76": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest" + ], + "2.1.32": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest", + "Setup" + ], + "2.1.33": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCompleted" + ], + "2.1.75": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded" + ], + "2.1.76": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded" + ], + "2.1.77": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded" + ], + "2.1.78": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded" + ], + "2.1.84": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ], + "2.1.90": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "PermissionDenied", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ], + "2.1.97": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "PermissionDenied", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ], + "2.1.100": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "PermissionDenied", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ], + "2.1.101": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "PermissionDenied", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ], + "2.1.110": [ + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Notification", + "UserPromptSubmit", + "SessionStart", + "SessionEnd", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "PermissionRequest", + "PermissionDenied", + "Setup", + "TeammateIdle", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", + "ConfigChange", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "CwdChanged", + "FileChanged" + ] + } +} diff --git a/src/main/claude/claude-agent-sdk-control-requests.test.ts b/src/main/claude/claude-agent-sdk-control-requests.test.ts index f76650d8151..01e822063e2 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.test.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.test.ts @@ -24,3 +24,29 @@ describe('createClaudeControlSurface stopTask', () => { expect(stopTask).toHaveBeenCalledTimes(2) }) }) + +/** A query exposing only the cancel method, as the surface reads nothing else for it. */ +function queryWithCancel(cancelAsyncMessage?: (uuid: string) => Promise): Query { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: cancelAsyncMessage reads only this member. + return (cancelAsyncMessage ? { cancelAsyncMessage } : {}) as unknown as Query +} + +describe('createClaudeControlSurface cancelAsyncMessage', () => { + it('reports a withdrawal only when the CLI answers cancelled: true', async () => { + // An older CLI answers with an empty success, which carries no `cancelled`. + const cancelAsyncMessage = vi + .fn<(uuid: string) => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(undefined) + const controls = createClaudeControlSurface(queryWithCancel(cancelAsyncMessage)) + + await expect(controls.cancelAsyncMessage('queued-1')).resolves.toBe(true) + await expect(controls.cancelAsyncMessage('queued-2')).resolves.toBe(false) + await expect(controls.cancelAsyncMessage('queued-3')).resolves.toBe(false) + expect(cancelAsyncMessage.mock.calls).toEqual([['queued-1'], ['queued-2'], ['queued-3']]) + await expect( + createClaudeControlSurface(queryWithCancel()).cancelAsyncMessage('queued-4') + ).resolves.toBe(false) + }) +}) diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts index f165822c937..d3313147102 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.ts @@ -94,7 +94,8 @@ export type ClaudeControlSurface = { interrupt: ( options?: ClaudeControlOptions & { cancelQueued?: boolean } ) => Promise - cancelAsyncMessage: (uuid: string, options?: ClaudeControlOptions) => Promise + /** True only when the CLI confirms it withdrew that message before it ran. */ + cancelAsyncMessage: (uuid: string, options?: ClaudeControlOptions) => Promise setModel: (model: string | undefined, options?: ClaudeControlOptions) => Promise setPermissionMode: (mode: PermissionMode, options?: ClaudeControlOptions) => Promise applyFlagSettings: ( @@ -131,9 +132,9 @@ export function createClaudeControlSurface(query: Query): ClaudeControlSurface { const cancel = claudeQueryAsyncCanceller(query) return cancel ? runClaudeControl('cancel_async_message', () => cancel(uuid), options?.timeoutMs).then( - () => {} + (cancelled) => cancelled === true ) - : Promise.resolve() + : Promise.resolve(false) }, setModel: (model, options) => runClaudeControl('set_model', () => query.setModel(model), options?.timeoutMs).then(() => {}), diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts new file mode 100644 index 00000000000..fc5d234ec87 --- /dev/null +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -0,0 +1,132 @@ +// A Claude that keeps retrying a refused request is working, so the idle sweep must not stop it. +// Every retry frame's publish is the activity the sweep reads, even though a run writes one row. +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 { 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' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const SWEEP_MS = 5 +const RETRY_GAP_MS = 10 * 60_000 + +let root: string +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink | null +let closeSession: Mock> +let clock: number + +function apiRetry(attempt: number): Record { + return { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 600_000, + error_status: 429, + error: 'rate_limit', + session_id: 'provider-1', + uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}` + } +} + +/** Long enough for many sweep ticks, so "still open" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-retry-sweep-')) + resetHostTestOperationIds() + sink = null + clock = NOW + closeSession = vi.fn(async () => true) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence, spawnToken, events }) => { + sink = events ?? null + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }, + closeSession, + releaseAcquisition: async () => true, + dispatch: async () => ({ state: 'admitted' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } + host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + idleSweep: { intervalMs: SWEEP_MS, idleMs: STRUCTURED_AGENT_SESSION_IDLE_MS }, + now: () => clock + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a Claude retrying a refused request', () => { + it('keeps the conversation open past the idle window while retry frames arrive', async () => { + expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + if (!sink) { + throw new Error('the host never handed the provider its event sink') + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: '1' }) + + // Five frames ten minutes apart: fifty minutes, well past the thirty-minute idle window. + for (let attempt = 1; attempt <= 5; attempt += 1) { + clock += RETRY_GAP_MS + translator.handle({ type: 'message', sessionId: SESSION, message: apiRetry(attempt) }) + await waitOutSeveralSweeps() + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + } + + const items = host['sessions'].get(SESSION)?.journal.snapshot().items ?? [] + const retryRows = items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerRetrying' + ) + expect(retryRows).toHaveLength(1) + expect(retryRows[0]?.body).toMatchObject({ + failure: { detail: { text: expect.stringContaining('"attempt":5') } } + }) + + // Once the frames stop, the same clock does let the sweep close it. + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + await vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + }) + }) +}) diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts new file mode 100644 index 00000000000..becadff90d3 --- /dev/null +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -0,0 +1,133 @@ +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, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { MAX_PROVIDER_DIAGNOSTIC_CHARS } from '../../shared/agent-session-failure' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +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' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } +} + +/** A frame as Claude Code sends it while it retries a refused request. */ +function apiRetry(attempt: number, fields: Record = {}): Record { + return { + type: 'system', + subtype: 'api_retry', + attempt, + max_retries: 10, + retry_delay_ms: 622, + error_status: 429, + error: 'rate_limit', + session_id: 'provider-1', + uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}`, + ...fields + } +} + +let root = '' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-api-retry-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function statusRowsFor(frames: Record[]) { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' }) + for (const frame of frames) { + translator.handle({ type: 'message', sessionId: 'orca-session', message: frame }) + await deferred.drained() + } + return journal + .snapshot() + .items.flatMap((item): Extract[] => + item.body.kind === 'status' ? [item.body] : [] + ) +} + +describe('a Claude api_retry frame', () => { + it('writes one sentence row per retry run, revised by each attempt, not the frame', async () => { + const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(3)]) + + expect(rows).toHaveLength(1) + const [row] = rows + expect(row).toMatchObject({ + text: 'Claude is rate-limited and retrying.', + tone: 'warning', + failure: { + kind: 'providerRetrying', + retry: { error: 'rate_limit', status: 429 }, + detail: { audience: 'log' } + } + }) + expect(row).not.toHaveProperty('providerFrame') + // The latest attempt's frame, as the log detail. + expect(row.failure?.detail?.text).toContain('"attempt":3') + }) + + it('starts a new row when a later run starts over', async () => { + const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(1)]) + expect(rows.map((row) => row.text)).toEqual([ + 'Claude is rate-limited and retrying.', + 'Claude is rate-limited and retrying.' + ]) + }) + + it('does not call a retry a rate limit unless the frame says so', async () => { + const rows = await statusRowsFor([ + apiRetry(1, { error: 'overloaded', error_status: 529 }), + apiRetry(1, { error: 'server_error', error_status: 500 }), + apiRetry(1, { error: undefined, error_status: undefined }), + apiRetry(1, { error: 'something_new', error_status: 429 }) + ]) + expect(rows.map((row) => row.text)).toEqual([ + 'Claude hit a temporary problem and is retrying.', + 'Claude hit a temporary problem and is retrying.', + 'Claude hit a temporary problem and is retrying.', + 'Claude is rate-limited and retrying.' + ]) + expect(rows[0]?.failure).toMatchObject({ retry: { error: 'overloaded', status: 529 } }) + }) + + it('caps the frame it keeps as the detail', async () => { + const [row] = await statusRowsFor([apiRetry(1, { padding: 'x'.repeat(5_000) })]) + expect(row?.failure?.detail?.text.length).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) + }) + + it('leaves a frame kind no one catalogued to the provider fallback, as before', async () => { + const unknown = { + type: 'system', + subtype: 'future_notice', + error: 'rate_limit', + session_id: 'provider-1', + uuid: 'future-1' + } + const rows = await statusRowsFor([unknown]) + expect(rows).toEqual([ + unhandledProviderFrameJournalItem('claude', 'message:system:future_notice', unknown)?.body + ]) + expect(rows[0]).toMatchObject({ text: 'rate_limit', tone: 'error' }) + }) +}) diff --git a/src/main/claude/claude-api-retry-row.ts b/src/main/claude/claude-api-retry-row.ts new file mode 100644 index 00000000000..195e30461a7 --- /dev/null +++ b/src/main/claude/claude-api-retry-row.ts @@ -0,0 +1,39 @@ +// The one row a Claude `system/api_retry` frame writes: a sentence and a `providerRetrying` fact, +// revised in place for every attempt of the same retry run. + +import { + agentSessionFailureFact, + providerDiagnostic, + readProviderRetry +} from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' + +export const CLAUDE_API_RETRY_FRAME_KIND = 'message:system:api_retry' + +export function claudeApiRetryRowBody(message: Record): AgentJournalStatusItem { + const retry = readProviderRetry({ error: message.error, status: message.error_status }) + const words = agentSessionFailureWords( + agentSessionFailureFact('providerRetrying', { + detail: providerDiagnostic(JSON.stringify(message), 'log'), + ...(retry ? { retry } : {}) + }), + { surface: 'row', agentName: TUI_AGENT_DISPLAY_NAMES.claude } + ) + return { kind: 'status', tone: 'warning', ...words } +} + +/** Numbers retry runs: a frame whose attempt does not follow the last one starts a new run. */ +export function createClaudeApiRetryRuns(): (message: Record) => number { + let run = 0 + let lastAttempt: number | null = null + return (message) => { + const attempt = typeof message.attempt === 'number' ? message.attempt : null + if (attempt === null || lastAttempt === null || attempt <= lastAttempt) { + run += 1 + } + lastAttempt = attempt + return run + } +} 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 83d9248f648..b562c839efd 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -18,10 +18,8 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal 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' -import { - readStructuredAgentSessionOptions, - type StructuredAgentSessionMutationContext -} from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' +import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' import { assistantFrame, initFrame, @@ -85,6 +83,11 @@ function readOptions( journal: AgentSessionJournal, adapter: Partial ) { + const running = { + journal, + child: { fence: 1, generation: 'generation-1' }, + params: { provider: 'claude' } + } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the options read touches only these members. const context = { deps: { @@ -95,7 +98,8 @@ function readOptions( store: { getRecord: () => undefined } }, serialize: (_sessionId: string, task: () => Promise) => task(), - requireSession: () => ({ journal, fence: 1 }) + openConversation: async () => running, + conversation: async () => running } as unknown as StructuredAgentSessionMutationContext return readStructuredAgentSessionOptions(context, SESSION) } diff --git a/src/main/claude/claude-hook-event-install.test.ts b/src/main/claude/claude-hook-event-install.test.ts new file mode 100644 index 00000000000..99af9a3fa53 --- /dev/null +++ b/src/main/claude/claude-hook-event-install.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import type { HooksConfig } from '../agent-hooks/installer-utils' +import fixture from './__fixtures__/claude-hook-event-enums.json' +import { CLAUDE_HOOK_EVENT_FIRST_VERSIONS } from './claude-hook-event-versions' +import { getClaudeManagedHookPlan } from './claude-managed-hook-events' +import { applyManagedHooks } from './hook-settings' + +const SCRIPT_FILE_NAME = 'claude-hook.sh' +const MANAGED_COMMAND = '/home/dev/.orca/agent-hooks/claude-hook.sh' +const managedHook = { type: 'command' as const, command: MANAGED_COMMAND } +const enums: Record = fixture.enums +const topLevelSettings: Record = fixture.topLevelSettings + +function install(config: HooksConfig, claudeVersion: string | undefined): HooksConfig { + return applyManagedHooks( + config, + managedHook, + SCRIPT_FILE_NAME, + getClaudeManagedHookPlan(claudeVersion) + ) +} + +function managedEvents(config: HooksConfig): string[] { + return Object.entries(config.hooks ?? {}) + .filter(([, definitions]) => + (definitions ?? []).some((definition) => + (definition.hooks ?? []).some((hook) => hook.command === MANAGED_COMMAND) + ) + ) + .map(([event]) => event) + .sort() +} + +const userHook = (event: string) => ({ + hooks: [{ type: 'command' as const, command: `echo user-${event}` }] +}) + +// Why: the user owns every event, managed or not, plus the keys an invalid file would discard. +function userOwnedSettings(): HooksConfig { + const events = [...Object.keys(CLAUDE_HOOK_EVENT_FIRST_VERSIONS), 'Notification'] + return { + env: { CLAUDE_CODE_USE_BEDROCK: '1' }, + permissions: { allow: ['Bash(git status)'] }, + hooks: Object.fromEntries(events.map((event) => [event, [userHook(event)]])) + } +} + +function expectUserSettingsKept(config: HooksConfig): void { + expect(config.env).toEqual({ CLAUDE_CODE_USE_BEDROCK: '1' }) + expect(config.permissions).toEqual({ allow: ['Bash(git status)'] }) + for (const [event, definitions] of Object.entries(userOwnedSettings().hooks ?? {})) { + expect(config.hooks?.[event], event).toContainEqual(definitions?.[0]) + } +} + +describe('Claude managed hook events by resolved version', () => { + it.each(Object.keys(enums))('writes only events Claude %s knows', (version) => { + const written = install({ hooks: {} }, version) + const unknown = Object.keys(written.hooks ?? {}).filter( + (event) => !enums[version].includes(event) + ) + expect(unknown).toEqual([]) + }) + + it.each(Object.keys(topLevelSettings))( + 'writes statusLine only if Claude %s knows it', + (version) => { + const plan = getClaudeManagedHookPlan(version) + expect(plan.statusLine === 'install').toBe( + topLevelSettings[version].keys.includes('statusLine') + ) + } + ) + + it('omits every event newer than an old Claude', () => { + expect(managedEvents(install({ hooks: {} }, '2.1.32 (Claude Code)'))).toEqual( + [ + 'PermissionRequest', + 'PostToolUse', + 'PostToolUseFailure', + 'PreToolUse', + 'SessionStart', + 'Stop', + 'SubagentStart', + 'SubagentStop', + 'UserPromptSubmit' + ].sort() + ) + }) + + it.each([undefined, 'unknown'])( + 'writes only the core lifecycle events when the version is %s', + (version) => { + expect(managedEvents(install({ hooks: {} }, version))).toEqual( + [ + 'PostToolUse', + 'PreToolUse', + 'SessionStart', + 'Stop', + 'SubagentStop', + 'UserPromptSubmit' + ].sort() + ) + } + ) + + it.each([undefined, 'unknown'])( + 'leaves Orca entries a version-aware install wrote untouched when the version is %s', + (version) => { + const current = install(userOwnedSettings(), '2.1.261') + expect(JSON.stringify(install(current, version))).toBe(JSON.stringify(current)) + + // Why: even an entry from an older hook command stays byte-identical; only a known version may rewrite it. + const staleHook = { + type: 'command' as const, + command: '/old/.orca/agent-hooks/claude-hook.sh' + } + const stale = { hooks: { StopFailure: [{ hooks: [staleHook] }] } } + const written = install(stale, version) + expect(JSON.stringify(written.hooks?.StopFailure)).toBe( + JSON.stringify(stale.hooks.StopFailure) + ) + expect(managedEvents(written)).toEqual( + [ + 'PostToolUse', + 'PreToolUse', + 'SessionStart', + 'Stop', + 'SubagentStop', + 'UserPromptSubmit' + ].sort() + ) + } + ) + + it('adds the newer events once the resolved Claude is upgraded', () => { + const old = install({ hooks: {} }, '2.1.77') + expect(old.hooks?.StopFailure).toBeUndefined() + + const upgraded = install(old, '2.1.78') + + expect(managedEvents(upgraded)).toContain('StopFailure') + expect(managedEvents(upgraded)).toContain('PostCompact') + expect(upgraded.hooks?.StopFailure).toEqual([{ hooks: [managedHook] }]) + }) + + it('removes only Orca entries for events a downgraded Claude does not know', () => { + const current = install({ hooks: {} }, '2.1.261') + expect(managedEvents(current)).toContain('SessionEnd') + + const downgraded = install(current, '2.1.32') + + for (const event of ['StopFailure', 'PostCompact', 'TeammateIdle', 'SessionEnd']) { + expect(downgraded.hooks?.[event], event).toBeUndefined() + } + }) + + it('gates a known Claude older than the unresolved-version set by its own enum', () => { + expect(managedEvents(install({ hooks: {} }, '1.0.52'))).toEqual( + ['PostToolUse', 'PreToolUse', 'Stop', 'SubagentStop'].sort() + ) + const downgraded = install(install({ hooks: {} }, '2.1.261'), '1.0.22') + expect(managedEvents(downgraded)).toEqual([]) + }) + + it.each(['1.0.22', '1.0.52', '1.0.81', '2.1.32', '2.1.77', '2.1.78', '2.1.261', undefined])( + 'keeps every user-written entry and setting when installing for %s', + (version) => { + const fresh = install(userOwnedSettings(), version) + expectUserSettingsKept(fresh) + // Why: a downgrade from the newest set is the path that removes Orca entries. + expectUserSettingsKept(install(install(userOwnedSettings(), '2.1.261'), version)) + } + ) + + it('leaves a user value it cannot parse under an unknown event untouched', () => { + const settings: HooksConfig = JSON.parse('{"hooks":{"StopFailure":[],"PostCompact":"mine"}}') + const written = install(settings, '2.1.32') + expect(written.hooks?.StopFailure).toEqual([]) + expect(written.hooks?.PostCompact).toBe('mine') + }) + + it('keeps SessionEnd behind its measured 2.1.261 floor', () => { + expect(install({ hooks: {} }, '2.1.260').hooks?.SessionEnd).toBeUndefined() + expect(install({ hooks: {} }, '2.1.261').hooks?.SessionEnd).toEqual([{ hooks: [managedHook] }]) + }) +}) diff --git a/src/main/claude/claude-hook-event-versions.test.ts b/src/main/claude/claude-hook-event-versions.test.ts new file mode 100644 index 00000000000..d27705a5f54 --- /dev/null +++ b/src/main/claude/claude-hook-event-versions.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { compareAppVersions } from '../../shared/app-version' +import fixture from './__fixtures__/claude-hook-event-enums.json' +import { + CLAUDE_HOOK_EVENT_FIRST_VERSIONS, + CLAUDE_STATUS_LINE_FIRST_VERSION, + claudeKnowsHookEvent, + claudeKnowsStatusLine, + parseClaudeCliVersion, + UNRESOLVED_CLAUDE_VERSION, + type ClaudeHookEventName +} from './claude-hook-event-versions' + +const enums: Record = fixture.enums +const previousPublished: Record = fixture.previousPublishedVersion +const topLevelSettings: Record = + fixture.topLevelSettings +const versions = Object.keys(enums).sort(compareAppVersions) +const isTableEvent = (name: string): name is ClaudeHookEventName => + name in CLAUDE_HOOK_EVENT_FIRST_VERSIONS +const tableEntries = Object.keys(CLAUDE_HOOK_EVENT_FIRST_VERSIONS) + .filter(isTableEvent) + .map((event): [ClaudeHookEventName, string] => [event, CLAUDE_HOOK_EVENT_FIRST_VERSIONS[event]]) + +describe('Claude hook event version table', () => { + it.each(tableEntries)('pins %s to the first release whose enum knows it', (event, first) => { + expect(versions.find((version) => enums[version].includes(event))).toBe(first) + for (const version of versions) { + expect(enums[version].includes(event), `${event} in ${version}`).toBe( + compareAppVersions(version, first) >= 0 + ) + } + // Why: the release published just before `first` lacks the event, so the table is exact, not just safe. + const before = previousPublished[first] + expect(enums[before], `enum for ${before}`).toBeDefined() + expect(enums[before]).not.toContain(event) + }) +}) + +describe('Claude statusLine version floor', () => { + it('pins statusLine to the first release whose settings schema knows it', () => { + for (const [version, schema] of Object.entries(topLevelSettings)) { + expect(schema.keys.includes('statusLine'), version).toBe( + compareAppVersions(version, CLAUDE_STATUS_LINE_FIRST_VERSION) >= 0 + ) + } + // Why: the release just before rejects the unknown key, so the floor is exact, not just safe. + const before = previousPublished[CLAUDE_STATUS_LINE_FIRST_VERSION] + expect(topLevelSettings[before].strict).toBe(true) + expect(topLevelSettings[before].keys).not.toContain('statusLine') + }) + + it.each([ + ['1.0.63', false], + ['1.0.64 (Claude Code)', true], + [undefined, true] + ] as const)('%s knows statusLine: %s', (version, expected) => { + expect(claudeKnowsStatusLine(version)).toBe(expected) + }) +}) + +describe('claudeKnowsHookEvent', () => { + it.each([ + ['2.1.77', 'StopFailure', false], + ['2.1.78 (Claude Code)', 'StopFailure', true], + ['2.1.75', 'PostCompact', false], + ['2.1.76', 'PostCompact', true], + ['2.1.32', 'TeammateIdle', false], + ['2.0.55', 'PostToolUseFailure', false], + ['2.0.44', 'PermissionRequest', false], + ['2.0.42', 'SubagentStart', false], + ['1.0.84', 'SessionEnd', false], + ['1.0.61', 'SessionStart', false], + ['1.0.62', 'SessionStart', true], + ['1.0.52', 'UserPromptSubmit', false], + ['1.0.40', 'SubagentStop', false], + ['1.0.30', 'Stop', false], + ['1.0.22', 'PreToolUse', false] + ] as const)('%s knows %s: %s', (version, event, expected) => { + expect(claudeKnowsHookEvent(version, event)).toBe(expected) + }) + + it.each([undefined, null, 'unknown'])( + 'grants an unresolved version (%s) only the events its assumed release knows', + (version) => { + const known = tableEntries + .filter(([event]) => claudeKnowsHookEvent(version, event)) + .map(([event]) => event) + expect(known.sort()).toEqual( + [ + 'PostToolUse', + 'PreToolUse', + 'SessionStart', + 'Stop', + 'SubagentStop', + 'UserPromptSubmit' + ].sort() + ) + for (const event of known) { + expect(enums[UNRESOLVED_CLAUDE_VERSION]).toContain(event) + } + } + ) +}) + +describe('parseClaudeCliVersion', () => { + it('extracts Claude Code version output', () => { + expect(parseClaudeCliVersion('2.1.261 (Claude Code)')).toBe('2.1.261') + expect(parseClaudeCliVersion('unknown')).toBeNull() + }) +}) diff --git a/src/main/claude/claude-hook-event-versions.ts b/src/main/claude/claude-hook-event-versions.ts new file mode 100644 index 00000000000..5c9c28de66c --- /dev/null +++ b/src/main/claude/claude-hook-event-versions.ts @@ -0,0 +1,86 @@ +import { hasReachedAppVersion, isValidAppVersion } from '../../shared/app-version' +import { runProcess } from '../../shared/child-process/run-process' +import path from 'node:path' + +// Why: Claude 1.0.23 through 2.1.100 validate `hooks` against a closed event enum and discard the +// WHOLE settings.json (env, permissions, every user hook) on one unknown name, so an event may only +// be written for a Claude that knows it. Values come from each release's packed enum, pinned by +// __fixtures__/claude-hook-event-enums.json. +export const CLAUDE_HOOK_EVENT_FIRST_VERSIONS = { + PreToolUse: '1.0.23', + PostToolUse: '1.0.23', + Stop: '1.0.31', + SubagentStop: '1.0.41', + UserPromptSubmit: '1.0.53', + SessionStart: '1.0.62', + SessionEnd: '1.0.85', + SubagentStart: '2.0.43', + PermissionRequest: '2.0.45', + PostToolUseFailure: '2.0.56', + TeammateIdle: '2.1.33', + PostCompact: '2.1.76', + StopFailure: '2.1.78' +} as const + +// Why: 1.0.49 through 1.0.66 also discard the whole file over an unknown top-level key, and +// `statusLine` joined their schema only in 1.0.64. Pinned by the same fixture's topLevelSettings. +export const CLAUDE_STATUS_LINE_FIRST_VERSION = '1.0.64' + +// Why: an unresolved version gets what the first release accepting Orca's core lifecycle events and +// statusLine knows, and nothing newer; a Claude older than that is gated only once its version resolves. +export const UNRESOLVED_CLAUDE_VERSION = CLAUDE_STATUS_LINE_FIRST_VERSION + +export type ClaudeHookEventName = keyof typeof CLAUDE_HOOK_EVENT_FIRST_VERSIONS + +export function parseClaudeCliVersion(output: string | null | undefined): string | null { + const version = output?.match(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/)?.[0] + return version && isValidAppVersion(version) ? version : null +} + +export function claudeVersionReaches(version: string | null | undefined, floor: string): boolean { + const parsed = parseClaudeCliVersion(version) + return parsed !== null && hasReachedAppVersion(parsed, floor) +} + +function claudeKnowsSince(version: string | null | undefined, firstVersion: string): boolean { + return hasReachedAppVersion( + parseClaudeCliVersion(version) ?? UNRESOLVED_CLAUDE_VERSION, + firstVersion + ) +} + +export function claudeKnowsHookEvent( + version: string | null | undefined, + eventName: ClaudeHookEventName +): boolean { + return claudeKnowsSince(version, CLAUDE_HOOK_EVENT_FIRST_VERSIONS[eventName]) +} + +export function claudeKnowsStatusLine(version: string | null | undefined): boolean { + return claudeKnowsSince(version, CLAUDE_STATUS_LINE_FIRST_VERSION) +} + +export async function probeClaudeCliVersion(executablePath: string): Promise { + try { + const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(executablePath) + const inheritedPath = process.env[pathKey] + const result = await runProcess({ + program: executablePath, + args: ['--version'], + // Why: version-manager launchers often use `#!/usr/bin/env node`; the resolved CLI's sibling + // runtime must remain reachable even when Electron started with a thinner PATH. + env: { + ...process.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return result.code === 0 ? parseClaudeCliVersion(`${result.stdout}\n${result.stderr}`) : null + } catch { + return null + } +} diff --git a/src/main/claude/claude-managed-hook-events.ts b/src/main/claude/claude-managed-hook-events.ts new file mode 100644 index 00000000000..dbbb8ae15a2 --- /dev/null +++ b/src/main/claude/claude-managed-hook-events.ts @@ -0,0 +1,124 @@ +import type { HookDefinition } from '../agent-hooks/installer-utils' +import { + claudeKnowsHookEvent, + claudeKnowsStatusLine, + claudeVersionReaches, + parseClaudeCliVersion +} from './claude-hook-event-versions' + +export const CLAUDE_EVENTS = [ + // Why: SessionStart is the only event a resumed/idle session emits before the + // first prompt; without it the sidebar row can't exist until the user types (STA-3386). + { + eventName: 'SessionStart', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'UserPromptSubmit', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'Stop', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + // Why: OpenClaude skips normal Stop hooks after API/model errors and emits + // StopFailure instead; without this hook Orca leaves the turn spinning. + { + eventName: 'StopFailure', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + // Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps + // a pane 'working' while background children outlive the lead's turn. + // TeammateIdle parks turn-based teammates without trusting their permanently + // "running" background_tasks entry to gate the pane. + { + eventName: 'SubagentStart', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'SubagentStop', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'TeammateIdle', + definition: { hooks: [{ type: 'command', command: '' }] } + }, + // Why: PreToolUse gives the dashboard a live readout of the in-flight tool + // (name + input preview) before it completes. + { + eventName: 'PreToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUseFailure', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PermissionRequest', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + // Why: a manual /compact ends at an idle prompt without emitting Stop, so PostCompact is the only + // signal that can clear the pane (STA-2915). PreCompact is deliberately NOT registered: it fires + // before the compact is validated, and an aborted compact emits it alone — mapping it to 'working' + // would strand the pane exactly as this registration is meant to prevent (STA-4613). + { + eventName: 'PostCompact', + definition: { hooks: [{ type: 'command', command: '' }] } + } +] as const + +const CLAUDE_SESSION_END_EVENT = { + eventName: 'SessionEnd', + // Why: Claude knows SessionEnd from 1.0.85, but 2.1.261 is the only version its delivery was measured on. + installFrom: '2.1.261', + definition: { hooks: [{ type: 'command', command: '' }] } +} as const + +export const CLAUDE_MANAGED_EVENTS = [...CLAUDE_EVENTS, CLAUDE_SESSION_END_EVENT] as const + +export type ManagedHookEvent = { + eventName: string + definition: Omit +} + +export type ClaudeManagedHookPlan = { + install: readonly ManagedHookEvent[] + /** Events whose Orca entry must go; user entries under them always stay. */ + retire: readonly ManagedHookEvent[] + /** Orca's statusLine usage feed; `retire` removes only Orca's own, `leave` never touches the slot. */ + statusLine: 'install' | 'retire' | 'leave' +} + +/** What to write for a Claude of this version; see claude-hook-event-versions.ts. */ +export function getClaudeManagedHookPlan( + claudeVersion: string | null | undefined +): ClaudeManagedHookPlan { + const install = CLAUDE_MANAGED_EVENTS.filter( + (event) => + claudeKnowsHookEvent(claudeVersion, event.eventName) && + (!('installFrom' in event) || claudeVersionReaches(claudeVersion, event.installFrom)) + ) + // Why: an unresolved version (e.g. a probe timeout) is no evidence of an old Claude, so it must + // not strip entries an install that knew the version wrote; only a known version retires events. + const retire = + parseClaudeCliVersion(claudeVersion) === null + ? [] + : CLAUDE_MANAGED_EVENTS.filter((event) => !install.includes(event)) + return { + install, + retire, + statusLine: claudeKnowsStatusLine(claudeVersion) ? 'install' : 'retire' + } +} + +// Why: OpenClaude reads its own settings file and accepts every event Orca writes. The statusline +// usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider. +export const OPENCLAUDE_MANAGED_HOOK_PLAN: ClaudeManagedHookPlan = { + install: CLAUDE_EVENTS, + retire: [CLAUDE_SESSION_END_EVENT], + statusLine: 'leave' +} diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index 882f716c3c9..f3a2646107c 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -60,7 +60,7 @@ export function createClaudeModelCatalogProbe( ...(model.description ? { description: model.description } : {}), isDefault: model.isDefault === true, // No defaultEffort: the listing's thinking default is the commit-message generator's - // choice, not the effort Claude runs, and a live session's listing never names one. + // choice, not the effort Claude runs; the store keeps the one a live child reported. efforts: (model.thinkingLevels ?? []).map((level) => ({ value: level.id, label: level.label diff --git a/src/main/claude/claude-session-end-hook-capability.test.ts b/src/main/claude/claude-session-end-hook-capability.test.ts deleted file mode 100644 index 755cdcbe498..00000000000 --- a/src/main/claude/claude-session-end-hook-capability.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - CLAUDE_SESSION_END_CAPABILITY_FLOOR, - claudeVersionSupportsSessionEnd, - parseClaudeCliVersion -} from './claude-session-end-hook-capability' - -describe('Claude SessionEnd hook version capability', () => { - it('records 2.1.261 as the measured floor', () => { - expect(CLAUDE_SESSION_END_CAPABILITY_FLOOR).toBe('2.1.261') - }) - - it('extracts Claude Code version output', () => { - expect(parseClaudeCliVersion('2.1.261 (Claude Code)')).toBe('2.1.261') - }) - - it.each([ - ['2.1.260', false], - ['2.1.261', true], - ['2.2.0', true], - ['unknown', false], - [undefined, false] - ])('classifies %s as SessionEnd-capable: %s', (version, expected) => { - expect(claudeVersionSupportsSessionEnd(version)).toBe(expected) - }) -}) diff --git a/src/main/claude/claude-session-end-hook-capability.ts b/src/main/claude/claude-session-end-hook-capability.ts deleted file mode 100644 index 1587c154e7b..00000000000 --- a/src/main/claude/claude-session-end-hook-capability.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { hasReachedAppVersion, isValidAppVersion } from '../../shared/app-version' -import { runProcess } from '../../shared/child-process/run-process' -import path from 'node:path' - -// 2.1.261 is the only version measured, not an established minimum. -export const CLAUDE_SESSION_END_CAPABILITY_FLOOR = '2.1.261' - -export function parseClaudeCliVersion(output: string | null | undefined): string | null { - const version = output?.match(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/)?.[0] - return version && isValidAppVersion(version) ? version : null -} - -export function claudeVersionSupportsSessionEnd(version: string | null | undefined): boolean { - const parsed = parseClaudeCliVersion(version) - return parsed !== null && hasReachedAppVersion(parsed, CLAUDE_SESSION_END_CAPABILITY_FLOOR) -} - -export async function probeClaudeCliVersion(executablePath: string): Promise { - try { - const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' - const executableDir = path.dirname(executablePath) - const inheritedPath = process.env[pathKey] - const result = await runProcess({ - program: executablePath, - args: ['--version'], - // Why: version-manager launchers often use `#!/usr/bin/env node`; the resolved CLI's sibling - // runtime must remain reachable even when Electron started with a thinner PATH. - env: { - ...process.env, - [pathKey]: inheritedPath - ? `${executableDir}${path.delimiter}${inheritedPath}` - : executableDir - }, - timeoutMs: 5_000, - maxOutputBytes: 4_096 - }) - return result.code === 0 ? parseClaudeCliVersion(`${result.stdout}\n${result.stderr}`) : null - } catch { - return null - } -} diff --git a/src/main/claude/claude-session-end-install.test.ts b/src/main/claude/claude-session-end-install.test.ts deleted file mode 100644 index 46f374ca7ae..00000000000 --- a/src/main/claude/claude-session-end-install.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { applyManagedHooks } from './hook-settings' - -const SCRIPT_FILE_NAME = 'claude-hook.sh' -const MANAGED_COMMAND = '/home/dev/.orca/agent-hooks/claude-hook.sh' -const managedHook = { type: 'command' as const, command: MANAGED_COMMAND } - -describe('Claude SessionEnd managed hook capability', () => { - it('installs SessionEnd beside SessionStart for the measured capable version', () => { - const written = applyManagedHooks({ hooks: {} }, managedHook, SCRIPT_FILE_NAME, { - claudeVersion: '2.1.261 (Claude Code)' - }) - - expect(written.hooks?.SessionEnd?.[0]?.hooks?.[0]?.command).toBe(MANAGED_COMMAND) - expect(written.hooks?.SessionStart?.[0]?.hooks?.[0]?.command).toBe(MANAGED_COMMAND) - }) - - it.each(['2.1.260', 'unknown', undefined])( - 'retains the legacy event set for an incapable or unverified host (%s)', - (claudeVersion) => { - const written = applyManagedHooks({ hooks: {} }, managedHook, SCRIPT_FILE_NAME, { - claudeVersion - }) - - expect(written.hooks?.SessionEnd).toBeUndefined() - expect(written.hooks?.SessionStart).toBeDefined() - } - ) - - it('removes only Orca SessionEnd during a capability downgrade', () => { - const capable = applyManagedHooks( - { - hooks: { - SessionEnd: [{ hooks: [{ type: 'command', command: 'echo user-session-end' }] }] - } - }, - managedHook, - SCRIPT_FILE_NAME, - { claudeVersion: '2.1.261' } - ) - const downgraded = applyManagedHooks(capable, managedHook, SCRIPT_FILE_NAME, { - claudeVersion: '2.1.260' - }) - - expect(downgraded.hooks?.SessionEnd).toEqual([ - { hooks: [{ type: 'command', command: 'echo user-session-end' }] } - ]) - }) -}) diff --git a/src/main/claude/claude-settings-schema-install.test.ts b/src/main/claude/claude-settings-schema-install.test.ts new file mode 100644 index 00000000000..577e455b6a3 --- /dev/null +++ b/src/main/claude/claude-settings-schema-install.test.ts @@ -0,0 +1,59 @@ +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 fixture from './__fixtures__/claude-hook-event-enums.json' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/userData' + } +})) + +import { ClaudeHookService } from './hook-service' + +const enums: Record = fixture.enums +const topLevelSettings: Record = + fixture.topLevelSettings +const strictReleases = Object.keys(topLevelSettings).filter( + (version) => topLevelSettings[version].strict +) + +// Why: checks the whole written file, so any new key Orca writes must pass each strict schema. +describe('Claude settings written for a strict settings schema', () => { + let tmpHome: string + let settingsPath: string + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-settings-schema-')) + settingsPath = join(tmpHome, '.claude', 'settings.json') + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + }) + + afterEach(() => { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + }) + + function expectAcceptedBy(version: string): void { + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')) + const { keys } = topLevelSettings[version] + expect(Object.keys(written).filter((key) => !keys.includes(key))).toEqual([]) + expect( + Object.keys(written.hooks ?? {}).filter((event) => !enums[version].includes(event)) + ).toEqual([]) + } + + it.each(strictReleases)('a fresh install writes only what Claude %s accepts', (version) => { + new ClaudeHookService().install({ claudeVersion: version }) + expectAcceptedBy(version) + }) + + it.each(strictReleases)('a downgrade to Claude %s leaves only what it accepts', (version) => { + const service = new ClaudeHookService() + service.install({ claudeVersion: '2.1.261' }) + service.install({ claudeVersion: version }) + expectAcceptedBy(version) + }) +}) diff --git a/src/main/claude/claude-status-line-install.test.ts b/src/main/claude/claude-status-line-install.test.ts new file mode 100644 index 00000000000..3c7c9c6f83c --- /dev/null +++ b/src/main/claude/claude-status-line-install.test.ts @@ -0,0 +1,75 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/userData' + } +})) + +import { ClaudeHookService } from './hook-service' + +const USER_STATUS_LINE = { type: 'command', command: '/usr/local/bin/my-statusline' } + +describe('Claude statusLine by resolved version', () => { + let tmpHome: string + let settingsPath: string + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-version-')) + settingsPath = join(tmpHome, '.claude', 'settings.json') + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + }) + + afterEach(() => { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + }) + + const readSettings = () => JSON.parse(readFileSync(settingsPath, 'utf-8')) + + it('writes no statusLine for a Claude whose settings schema rejects it', () => { + new ClaudeHookService().install({ claudeVersion: '1.0.63' }) + expect(readSettings().statusLine).toBeUndefined() + expect(readSettings().hooks.SessionStart).toBeDefined() + }) + + it('removes Orca statusLine on a downgrade and re-adds it after the upgrade', () => { + const service = new ClaudeHookService() + service.install({ claudeVersion: '2.1.261' }) + expect(readSettings().statusLine?.command).toContain('claude-statusline') + + service.install({ claudeVersion: '1.0.63' }) + expect(readSettings().statusLine).toBeUndefined() + + // Why: Orca's own removal must not read as the user's opt-out on the next capable install. + service.install({ claudeVersion: '1.0.64' }) + expect(readSettings().statusLine?.command).toContain('claude-statusline') + }) + + it('keeps the user opt-out across a downgrade and upgrade', () => { + const service = new ClaudeHookService() + service.install({ claudeVersion: '2.1.261' }) + const { statusLine: _optedOut, ...rest } = readSettings() + writeFileSync(settingsPath, JSON.stringify(rest)) + + service.install({ claudeVersion: '1.0.63' }) + service.install({ claudeVersion: '2.1.261' }) + expect(readSettings().statusLine).toBeUndefined() + }) + + it('never removes a user statusLine for an old Claude', () => { + mkdirSync(join(tmpHome, '.claude'), { recursive: true }) + writeFileSync(settingsPath, JSON.stringify({ statusLine: USER_STATUS_LINE })) + new ClaudeHookService().install({ claudeVersion: '1.0.63' }) + expect(readSettings().statusLine).toEqual(USER_STATUS_LINE) + }) + + it('keeps installing the statusLine when the version is unresolved', () => { + new ClaudeHookService().install() + expect(readSettings().statusLine?.command).toContain('claude-statusline') + }) +}) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 51ffa085163..946cf08e1c4 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate' @@ -592,6 +593,11 @@ describe('Claude stream-json connection', () => { await until(() => exit, 'the exit error') // The status and stderr are the only diagnostic a refused start leaves behind. expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/) + // Kept apart from Orca's wording where it is composed, and marked as log text. + expect(providerDiagnosticOf(exit)).toEqual({ + text: expect.stringMatching(/^\(code 1\)\n.*claude: not signed in/s), + audience: 'log' + }) expect(connection.closed).toBe(true) // Stderr-triggered capture can win or lose the race with this real child's exit. const closed = await connection.close() diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index c6da4419f79..486407c0137 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type * as ClaudeAgentSdk from '@anthropic-ai/claude-agent-sdk' import type { CanUseTool, OnUserDialog, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { spawnProcess } from '../../shared/child-process/run-process' @@ -103,7 +104,12 @@ function exitError(stderrTail: string, status: ExitStatus | null, cause?: Error) ? ` (code ${status.code})` : '' const message = `claude stream-json exited${how}${detail ? `: ${detail}` : ''}` - return cause ? new Error(message, { cause }) : new Error(message) + // Written for a log, not a person: the chat keeps it behind Details. + const diagnostic = providerDiagnostic([how.trim(), detail].filter(Boolean).join('\n'), 'log') + return withProviderDiagnostic( + cause ? new Error(message, { cause }) : new Error(message), + diagnostic + ) } export async function openClaudeStreamJsonConnection( diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index ddc69366aad..002240e4dd7 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -105,7 +105,10 @@ describe('claude structured auth parity with the terminal preflight', () => { resolverFor({ stripAuthEnv: true, overlay: { ANTHROPIC_API_KEY: 'sk-ant-CONFIGURED' } })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + reason: 'managedAccountEnvOverride' + }) }) it('refuses an auth-like ANTHROPIC_CUSTOM_HEADERS override while a managed account is pinned', async () => { @@ -114,7 +117,10 @@ describe('claude structured auth parity with the terminal preflight', () => { stripAuthEnv: true, overlay: { ANTHROPIC_CUSTOM_HEADERS: 'Authorization: Bearer sk-ant-CONFIGURED' } })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, + reason: 'managedAccountEnvOverride' + }) }) it('still admits a non-auth env overlay under a managed account', async () => { @@ -161,7 +167,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( resolverFor({ stripAuthEnv: true, authSwitchSettleTimeoutMs: 20 })({ identity: IDENTITY }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) }) it('waits a settling account switch out rather than refusing a resolved launch', async () => { @@ -183,7 +192,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) // Nothing was spawned, so the refusal must not have opened a connection. expect(claude.connections).toHaveLength(0) }) @@ -227,7 +239,10 @@ describe('claude structured auth parity with the terminal preflight', () => { await expect( adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) - ).rejects.toThrow(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + ).rejects.toMatchObject({ + message: CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, + reason: 'accountSwitchInProgress' + }) // No replacement child was opened, so nothing is left running unowned. expect(claude.connections).toHaveLength(1) await adapter.closeAll() diff --git a/src/main/claude/claude-structured-compaction.test.ts b/src/main/claude/claude-structured-compaction.test.ts index 5255ce37ea1..70aabc5ae50 100644 --- a/src/main/claude/claude-structured-compaction.test.ts +++ b/src/main/claude/claude-structured-compaction.test.ts @@ -22,7 +22,7 @@ describe('Claude compaction transcript content', () => { } } expect(isClaudeCompactionContent(tracker, event)).toBe(false) - const completion = tracker.run('orca-session', 'provider', async () => ({})) + 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( @@ -46,7 +46,7 @@ describe('Claude compaction transcript content', () => { await vi.advanceTimersByTimeAsync(1) - await expect(pending).resolves.toEqual({ error: 'provider_write_failed: input closed' }) + await expect(pending).resolves.toEqual({ outcome: 'failed' }) }) it('keeps waiting when the command write outcome is ambiguous', async () => { diff --git a/src/main/claude/claude-structured-compaction.ts b/src/main/claude/claude-structured-compaction.ts index 6c3155739c2..5b1f67e2e11 100644 --- a/src/main/claude/claude-structured-compaction.ts +++ b/src/main/claude/claude-structured-compaction.ts @@ -1,5 +1,8 @@ import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' +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 @@ -9,7 +12,7 @@ export function compactClaudeSession( session: ClaudeSession, compactions: StructuredSessionCompaction, input: Parameters>[0] -): Promise<{ error?: string }> { +): Promise { return compactions.run( input.sessionId, session.providerSessionId, @@ -18,7 +21,7 @@ export function compactClaudeSession( body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] } }) if (result.state === 'rejected') { - return { error: result.reason } + return { outcome: 'failed' as const } } return undefined }, diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index a04273ac126..3eaf496f75a 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -16,7 +16,7 @@ type InterruptResult = Awaited Promise - cancelAsyncMessage?: (uuid: string) => Promise + cancelAsyncMessage?: (uuid: string) => Promise prompts?: ClaudePromptRegistry }): { session: ClaudeSession @@ -24,7 +24,7 @@ function sessionWith(input: { cancelAsyncMessage: ReturnType } { const interrupt = vi.fn(input.interrupt) - const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => {})) + const cancelAsyncMessage = vi.fn(input.cancelAsyncMessage ?? (async () => false)) const session = sessionFor() session.capabilities = input.capabilities ?? [] session.prompts = input.prompts ?? new ClaudePromptRegistry() @@ -56,6 +56,53 @@ describe('cancelClaudeTurn', () => { expect(cancelAsyncMessage.mock.calls.map((call) => call[0])).toEqual(['queued-1', 'queued-2']) }) + it('settles each still-queued send the CLI confirms it withdrew, and only those', async () => { + const { session, cancelAsyncMessage } = sessionWith({ + capabilities: ['interrupt_receipt_v1'], + interrupt: async () => ({ still_queued: ['queued-1', 'queued-2', 'queued-3'] }), + // queued-2 already ran; queued-3's answer never arrived. + cancelAsyncMessage: async (uuid) => { + if (uuid === 'queued-3') { + throw new ClaudeControlRequestError('cancel_async_message', 'timed out') + } + return uuid === 'queued-1' + } + }) + const resolutions = [vi.fn(), vi.fn(), vi.fn()] + session.dispatchWaiters = ['queued-1', 'queued-2', 'queued-3'].map( + (sentUuid, index): ClaudeDispatchWaiter => ({ + acceptsResult: false, + clientMessageId: `client-${index + 1}`, + sentUuid, + dispatchSequence: index + 1, + requestedAt: null, + replayContentKey: `content-${index}`, + resolve: resolutions[index]! + }) + ) + const settled = vi.fn() + + await expect(cancelClaudeTurn(session, 5_000, () => true, settled)).resolves.toEqual({ + cancelled: true + }) + expect(cancelAsyncMessage).toHaveBeenCalledTimes(3) + expect(settled.mock.calls).toEqual([ + [ + { + clientMessageId: 'client-1', + state: 'rejected', + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } + } + ] + ]) + expect(resolutions[0]).toHaveBeenCalledWith(null) + expect(session.dispatchWaiters.map((waiter) => waiter.sentUuid)).toEqual([ + 'queued-2', + 'queued-3' + ]) + }) + it('settles every cancelled queued waiter when the CLI advertises the capability', async () => { const cancelled = Array.from({ length: 64 }, (_, index) => `queued-${index}`) const { session, interrupt, cancelAsyncMessage } = sessionWith({ @@ -85,7 +132,8 @@ describe('cancelClaudeTurn', () => { expect(settled).toHaveBeenNthCalledWith(1, { clientMessageId: 'client-0', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) @@ -118,7 +166,8 @@ describe('cancelClaudeTurn', () => { expect(settled).toHaveBeenCalledWith({ clientMessageId: 'client-ambiguous', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 5e5906c4216..8c1a6462e9c 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -19,8 +19,9 @@ export type ClaudeTurnCancellationGuard = () => boolean * Interrupt the running turn, then make sure no queued async user message survives to spawn a * later unexpected turn. On a CLI advertising `interrupt_cancel_queued_v1` one round trip * cancels the queue alongside the abort; otherwise the interrupt receipt lists `still_queued` - * uuids, and each is withdrawn best-effort with `cancel_async_message`. Older CLIs resolve no - * receipt, so there is nothing to sweep. + * uuids, and each is withdrawn best-effort with `cancel_async_message`. Either way, every send + * the CLI confirms it withdrew settles as cancelled. Older CLIs resolve no receipt, so there is + * nothing to sweep. */ export async function cancelClaudeTurn( session: ClaudeSession, @@ -42,9 +43,13 @@ export async function cancelClaudeTurn( if (cancelQueued) { settleCancelledClaudeDispatchWaiters(session, receipt?.cancelled ?? [], onDispatchSettledLate) } else { + const withdrawn: string[] = [] for (const uuid of receipt?.still_queued ?? []) { - await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => {}) + if (await session.connection.cancelAsyncMessage(uuid, { timeoutMs }).catch(() => false)) { + withdrawn.push(uuid) + } } + settleCancelledClaudeDispatchWaiters(session, withdrawn, onDispatchSettledLate) } return { cancelled: true } } catch (error) { diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts index 9a4d60a824c..75553addd3c 100644 --- a/src/main/claude/claude-structured-dispatch-admission.test.ts +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -139,7 +139,11 @@ describe('Claude structured dispatch admission', () => { clientMessageId: 'client-over-capacity', body: userMessage([{ type: 'text', text: 'one too many' }]) }) - ).resolves.toEqual({ state: 'rejected', reason: 'claude structured dispatch queue is full' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'claude structured dispatch queue is full', + rejection: { kind: 'queueFull' } + }) expect(session.dispatchWaiters).toHaveLength(64) expect(session.connection.send).toHaveBeenCalledTimes(64) }) diff --git a/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts new file mode 100644 index 00000000000..a23767c0968 --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-attachment-rejection.test.ts @@ -0,0 +1,177 @@ +// What a person reads when Orca refuses a message's content before sending it. + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' + +describe('Claude structured dispatch attachment rejections', () => { + it('rejects more than twenty URL images before sending', async () => { + const session = sessionFor() + const body = userMessage( + Array.from({ length: 21 }, (_, index) => ({ + type: 'image-ref' as const, + url: `https://example.test/${index}.png` + })) + ) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'Claude accepts at most 20 images in one message, so this message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } } + }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + + it('rejects local images whose aggregate size exceeds twenty MiB', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) + try { + const paths = await Promise.all( + Array.from({ length: 5 }, async (_, index) => { + const path = join(directory, `${index}.png`) + await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) + return path + }) + ) + const session = sessionFor() + const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: + 'The images on this message add up to more than 20 MB, so the message was not sent.', + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'totalTooLarge', limit: 20 * 1024 * 1024 } + } + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('rejects a local image by actual bytes read beyond the per-image cap', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'oversized.png') + await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) + const session = sessionFor() + const body = userMessage([{ type: 'image-ref', path }]) + + await expect( + dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'An image on this message is larger than 5 MB, so the message was not sent.', + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'tooLarge', limit: 5 * 1024 * 1024 } + } + }) + expect(session.connection.send).not.toHaveBeenCalled() + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('names an empty image as empty, not as too large', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'empty.png') + await writeFile(path, Buffer.alloc(0)) + const session = sessionFor() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'An image on this message is empty, so the message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'empty' } } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('names an unsupported image type in words a person can act on', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) + try { + const path = join(directory, 'picture.bmp') + await writeFile(path, Buffer.alloc(64)) + const session = sessionFor() + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: + 'Claude accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'unsupportedType' } } + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + it('does not blame an attachment for a message no Orca client sends', async () => { + const session = sessionFor() + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: '' }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'This message is empty, so it was not sent.', + rejection: { kind: 'emptyMessage' } + }) + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-2', + body: { ...userMessage([{ type: 'text', text: 'hi' }]), role: 'assistant' } + }) + ).resolves.toEqual({ + state: 'rejected', + reason: "Orca ran into a problem, so this didn't go through. Try again.", + rejection: { kind: 'hostFault' } + }) + expect(session.connection.send).not.toHaveBeenCalled() + }) + + it('rejects an attachment it cannot read with the generic sentence and no path', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const session = sessionFor() + const path = join(tmpdir(), 'orca-claude-image-missing', 'gone.png') + + await expect( + dispatchClaudeTurn(session, { + clientMessageId: 'client-1', + body: userMessage([{ type: 'image-ref', path }]) + }) + ).resolves.toEqual({ + state: 'rejected', + reason: "An attachment on this message couldn't be read, so the message was not sent.", + rejection: { kind: 'attachmentUnreadable' } + }) + expect(session.connection.send).not.toHaveBeenCalled() + // The row drops the error, so the log is the only place left to find why. + expect(warn).toHaveBeenCalledWith( + '[claude-dispatch] attachment could not be read:', + expect.objectContaining({ code: 'ENOENT' }) + ) + warn.mockRestore() + }) +}) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 7df84091b7c..035b0a59492 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -3,11 +3,63 @@ import { open } from 'node:fs/promises' import { extname } from 'node:path' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + agentSessionFailureFact, + type AgentSessionAttachmentProblem, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' import { claudeRecord } from './claude-structured-item-translation' -const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +/** Orca refused the message's content, as opposed to failing to read an attachment. */ +export class ClaudeDispatchContentError extends Error { + /** What was wrong, typed where the check saw it; `message` stays for logs. */ + readonly failure: SubmissionRejectionFact + + constructor(message: string, failure: SubmissionRejectionFact) { + super(message) + this.name = 'ClaudeDispatchContentError' + this.failure = failure + } +} + +function attachmentError(message: string, attachment: AgentSessionAttachmentProblem): Error { + return new ClaudeDispatchContentError( + message, + agentSessionFailureFact('attachmentInvalid', { attachment }) + ) +} + +/** A message Claude rejected, in the words that name Claude and its legacy markers. */ +export function claudeDispatchRejection( + failure: SubmissionRejectionFact +): AgentJournalDispatchRejection { + return agentSessionFailureWords(failure, { + surface: 'rejection', + agentName: TUI_AGENT_DISPLAY_NAMES.claude, + provider: 'claude' + }) +} + +/** Why a message whose content could not be built was not sent: Orca's own refusal of it, or an + * attachment it could not read. Never the provider. */ +export function claudeDispatchContentRejection(error: unknown): AgentJournalDispatchRejection { + if (error instanceof ClaudeDispatchContentError) { + return claudeDispatchRejection(error.failure) + } + // The row says only that it could not be read; why belongs in the log. + console.warn('[claude-dispatch] attachment could not be read:', error) + return claudeDispatchRejection(agentSessionFailureFact('attachmentUnreadable')) +} + +const BYTES_PER_MB = 1024 * 1024 +const MAX_IMAGE_BYTES = 5 * BYTES_PER_MB const MAX_IMAGE_COUNT = 20 -const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024 +const MAX_TOTAL_IMAGE_BYTES = 20 * BYTES_PER_MB const MAX_REPLAY_CONTENT_KEY_BYTES = 256 type ImageBudget = { @@ -18,14 +70,17 @@ type ImageBudget = { export async function readClaudeImage(path: string, openImpl: typeof open = open): Promise { const file = await openImpl(path, 'r') try { - const invalidImage = (): Error => - new Error(`Claude image must be a non-empty file no larger than ${MAX_IMAGE_BYTES} bytes`) + const tooLarge = (): Error => + attachmentError(`Claude image must be no larger than ${MAX_IMAGE_BYTES} bytes`, { + reason: 'tooLarge', + limit: MAX_IMAGE_BYTES + }) const info = await file.stat() if (!info.isFile()) { - throw new Error('Claude image must be a file') + throw attachmentError('Claude image must be a file', { reason: 'notAFile' }) } if (info.size > MAX_IMAGE_BYTES) { - throw invalidImage() + throw tooLarge() } const buffer = Buffer.allocUnsafe(info.size + 1) let bytesRead = 0 @@ -39,8 +94,15 @@ export async function readClaudeImage(path: string, openImpl: typeof open = open // A file can grow after the initial stat and after the final read returns // zero. Prove the descriptor's size matches what was copied before sending. const finalInfo = await file.stat() - if (bytesRead === 0 || bytesRead > MAX_IMAGE_BYTES || finalInfo.size !== bytesRead) { - throw invalidImage() + if (bytesRead > MAX_IMAGE_BYTES) { + throw tooLarge() + } + if (finalInfo.size !== bytesRead) { + // Not the image's fault: it changed while Orca read it, so it reads as unreadable. + throw new Error('Claude image changed while it was read') + } + if (bytesRead === 0) { + throw attachmentError('Claude image must be a non-empty file', { reason: 'empty' }) } return buffer.subarray(0, bytesRead) } finally { @@ -62,22 +124,30 @@ async function imageContent( ): Promise { budget.count += 1 if (budget.count > MAX_IMAGE_COUNT) { - throw new Error(`Claude messages support at most ${MAX_IMAGE_COUNT} images`) + throw attachmentError(`Claude messages support at most ${MAX_IMAGE_COUNT} images`, { + reason: 'tooMany', + limit: MAX_IMAGE_COUNT + }) } if (block.url) { return { type: 'image', source: { type: 'url', url: block.url } } } if (!block.path) { - throw new Error('image reference has neither a path nor a URL') + throw attachmentError('image reference has neither a path nor a URL', { reason: 'noSource' }) } const data = await readClaudeImage(block.path) budget.localBytes += data.byteLength if (budget.localBytes > MAX_TOTAL_IMAGE_BYTES) { - throw new Error(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`) + throw attachmentError(`Claude images must total no more than ${MAX_TOTAL_IMAGE_BYTES} bytes`, { + reason: 'totalTooLarge', + limit: MAX_TOTAL_IMAGE_BYTES + }) } const mediaType = IMAGE_MIME_BY_EXTENSION[extname(block.path).toLowerCase()] if (!mediaType) { - throw new Error(`Claude does not support the image type ${extname(block.path)}`) + throw attachmentError(`Claude does not support the image type ${extname(block.path)}`, { + reason: 'unsupportedType' + }) } return { type: 'image', @@ -99,7 +169,11 @@ export async function claudeDispatchMessageContent( body: AgentJournalMessageItem ): Promise { if (body.role !== 'user') { - throw new Error('Claude dispatch accepts only user messages') + // No Orca client sends one, so the fault is Orca's. + throw new ClaudeDispatchContentError( + 'Claude dispatch accepts only user messages', + agentSessionFailureFact('hostFault') + ) } const images: unknown[] = [] const texts: string[] = [] @@ -115,7 +189,10 @@ export async function claudeDispatchMessageContent( // text blocks would silently discard every one but the last. const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images if (content.length === 0) { - throw new Error('Claude dispatch requires text or an image') + throw new ClaudeDispatchContentError( + 'Claude dispatch requires text or an image', + agentSessionFailureFact('emptyMessage') + ) } return content } diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 1945ac3531d..c540167de4c 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { dispatchClaudeTurn, resolveClaudeReplayTurn } from './claude-structured-dispatch' -import { readClaudeImage } from './claude-structured-dispatch-content' +import { ClaudeDispatchContentError, readClaudeImage } from './claude-structured-dispatch-content' import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' import type { ClaudeSession } from './claude-structured-session-state' import { @@ -379,7 +379,11 @@ describe('Claude structured dispatch image limits', () => { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed', + rejection: { kind: 'writeFailed' } + }) expect(session.dispatchWaiters).toEqual([firstWaiter]) const firstUuid = (firstWaiter as { sentUuid?: string }).sentUuid @@ -392,6 +396,7 @@ describe('Claude structured dispatch image limits', () => { }) it('does not let a provably unwritten attempt block retry correlation', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const send = vi .fn() .mockRejectedValueOnce(claudeUnwrittenUserMessageError(new Error('broken pipe'))) @@ -401,7 +406,17 @@ describe('Claude structured dispatch image limits', () => { await expect( dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed', + rejection: { kind: 'writeFailed' } + }) + // The row keeps only the marker; why the write failed goes to the log. + expect(warn).toHaveBeenCalledWith( + '[claude-dispatch] message could not be handed to Claude:', + expect.objectContaining({ message: expect.stringContaining('broken pipe') }) + ) + warn.mockRestore() expect(session.dispatchWaiters).toHaveLength(0) expect(session.retiredDispatchWaiters).toHaveLength(0) @@ -684,66 +699,6 @@ describe('Claude structured dispatch image limits', () => { }) }) - it('rejects more than twenty URL images before sending', async () => { - const session = sessionFor() - const body = userMessage( - Array.from({ length: 21 }, (_, index) => ({ - type: 'image-ref' as const, - url: `https://example.test/${index}.png` - })) - ) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ state: 'rejected', reason: 'Claude messages support at most 20 images' }) - expect(session.connection.send).not.toHaveBeenCalled() - }) - - it('rejects local images whose aggregate size exceeds twenty MiB', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-claude-images-')) - try { - const paths = await Promise.all( - Array.from({ length: 5 }, async (_, index) => { - const path = join(directory, `${index}.png`) - await writeFile(path, Buffer.alloc(5 * 1024 * 1024)) - return path - }) - ) - const session = sessionFor() - const body = userMessage(paths.map((path) => ({ type: 'image-ref' as const, path }))) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ - state: 'rejected', - reason: `Claude images must total no more than ${20 * 1024 * 1024} bytes` - }) - expect(session.connection.send).not.toHaveBeenCalled() - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) - - it('rejects a local image by actual bytes read beyond the per-image cap', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) - try { - const path = join(directory, 'oversized.png') - await writeFile(path, Buffer.alloc(5 * 1024 * 1024 + 1)) - const session = sessionFor() - const body = userMessage([{ type: 'image-ref', path }]) - - await expect( - dispatchClaudeTurn(session, { clientMessageId: 'client-1', body }) - ).resolves.toEqual({ - state: 'rejected', - reason: `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` - }) - expect(session.connection.send).not.toHaveBeenCalled() - } finally { - await rm(directory, { recursive: true, force: true }) - } - }) - it('allocates local image reads from the file size, not the maximum cap', async () => { const directory = await mkdtemp(join(tmpdir(), 'orca-claude-image-')) const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe') @@ -821,8 +776,11 @@ describe('Claude structured dispatch image limits', () => { read, close: vi.fn().mockResolvedValue(undefined) } as never) - await expect(readClaudeImage('/controlled/growing.png', open)).rejects.toThrow( - `Claude image must be a non-empty file no larger than ${5 * 1024 * 1024} bytes` + // It changed while Orca read it: unreadable, never a limit the image did not break. + const rejected = await readClaudeImage('/controlled/growing.png', open).catch( + (error: unknown) => error ) + expect(rejected).not.toBeInstanceOf(ClaudeDispatchContentError) + expect(rejected).toMatchObject({ message: 'Claude image changed while it was read' }) }) }) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 5a5b2796595..220542fab91 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -19,22 +19,22 @@ import type { import { readClaudeFrameString } from './claude-structured-init-proof' import { claudeDispatchContentKey, + claudeDispatchContentRejection, claudeDispatchInvokesSlashCommand, - claudeDispatchMessageContent + claudeDispatchMessageContent, + claudeDispatchRejection } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' -import { - DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTED_QUEUE_FULL, - dispatchWriteFailureReason -} from '../../shared/structured-agent-session-dispatch-rejection' +import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import { claudeUnwrittenUserMessageError, claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' import { - claudeStartupFailureReason, + claudeStartupFailureFact, failClaudeStartup } from './claude-structured-session-startup-state' @@ -214,7 +214,7 @@ export function settleCancelledClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + ...claudeDispatchRejection(agentSessionFailureFact('cancelled')) }) } } @@ -231,6 +231,12 @@ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { } } +/** The row keeps only the marker released clients hide; why the write failed belongs in the log. */ +function claudeWriteFailureRejection(error: unknown): AgentJournalDispatchRejection { + console.warn('[claude-dispatch] message could not be handed to Claude:', error) + return claudeDispatchRejection(agentSessionFailureFact('writeFailed')) +} + export async function dispatchClaudeTurn( session: ClaudeSession, input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, @@ -240,14 +246,14 @@ export async function dispatchClaudeTurn( try { content = await claudeDispatchMessageContent(input.body) } catch (error) { - return { state: 'rejected', reason: (error as Error).message } + return { state: 'rejected', ...claudeDispatchContentRejection(error) } } if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { - return { state: 'rejected', reason: DISPATCH_REJECTED_QUEUE_FULL } + return { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact('queueFull')) } } - const startupFailure = claudeStartupFailureReason(session) + const startupFailure = claudeStartupFailureFact(session) if (startupFailure) { - return { state: 'rejected', reason: startupFailure } + return { state: 'rejected', ...claudeDispatchRejection(startupFailure) } } // 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. @@ -293,7 +299,7 @@ export async function dispatchClaudeTurn( if (error instanceof AgentSessionPreDispatchError) { throw error } - return { state: 'rejected', reason: dispatchWriteFailureReason(error) } + return { state: 'rejected', ...claudeWriteFailureRejection(error) } } const waiter = replay.waiter if (waiter.settledUuid) { @@ -311,7 +317,7 @@ export async function dispatchClaudeTurn( waiter.resolve(null) // The frame was never handed to the SDK's input pump, so this is not doubt: // the message provably did not happen, which is what `rejected` means. - return { state: 'rejected', reason: dispatchWriteFailureReason(error) } + return { state: 'rejected', ...claudeWriteFailureRejection(error) } } if (!waiter.retired) { retireWaiter(session, waiter) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts new file mode 100644 index 00000000000..2175ef49e43 --- /dev/null +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -0,0 +1,256 @@ +// A Claude chat at rest shows the effort its next start will run: a live child teaches the host +// catalog what the CLI runs for each model when no effort is sent, and the resting read answers it. + +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { getAgentSessionOptionCatalog } from '../../shared/agent-session-option-catalog' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' +import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionEvent +} from './claude-structured-session-adapter' +import { + PROVIDER_SESSION_ID, + fakeClaude, + identityFor, + recordingJournalSink +} from './claude-structured-session-test-support' + +const SESSION = 'session-1' +const ACCOUNT_HOME = '/accounts/claude' +const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] + +/** Claude Code 2.1.280's list_models rows: `default` resolves to the opus row. */ +const CATALOG = [ + { + value: 'default', + resolvedModel: 'claude-opus-5-5[1m]', + displayName: 'Default (recommended)', + supportsEffort: true, + supportedEffortLevels: EFFORTS + }, + { + value: 'opus[1m]', + resolvedModel: 'claude-opus-5-5[1m]', + displayName: 'Opus (1M context)', + supportsEffort: true, + supportedEffortLevels: EFFORTS + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: EFFORTS + } +] + +/** get_settings with nothing overriding the CLI's own default; an effort write moves both views. */ +function settingsWithNoOverride() { + const effective: { effortLevel?: string } = {} + const settings = { + effective, + sources: [], + applied: { model: 'claude-opus-5-5[1m]', effort: 'medium', advisor: null, ultracode: false } + } + const claude = fakeClaude({ + initProof: 'session-start', + initModels: CATALOG, + settings, + routes: { + list_models: () => CATALOG, + apply_flag_settings: (params) => { + const written = params?.settings + const effort = + typeof written === 'object' && written !== null && 'effortLevel' in written + ? written.effortLevel + : undefined + if (typeof effort === 'string') { + effective.effortLevel = effort + settings.applied.effort = effort + } + } + } + }) + return claude +} + +async function startChild( + store: AgentModelCatalogStore, + options?: Record, + events: ClaudeStructuredSessionEvent[] = [] +): Promise { + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: ACCOUNT_HOME, + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + onEvent: (event) => events.push(event), + openConnection: settingsWithNoOverride().openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {}, + modelCatalog: store + }) + await adapter.acquire({ + identity: identityFor(SESSION), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink(), + ...(options ? { options } : {}) + }) + await adapter.awaitStarted(SESSION) + return adapter +} + +function restingRecord(options: Record): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. + return { + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, + location: { wslDistro: null }, + options + } as unknown as AgentSessionRecord +} + +function catalogDefault(store: AgentModelCatalogStore, model: string): string | undefined { + const entry = store.get(agentModelCatalogFingerprintForRecord(restingRecord({}))) + return entry?.models.find((row) => row.id === model)?.defaultEffort +} + +/** The options a client reads for the chat once its child is gone. */ +function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { + const modelCatalog = createAgentModelCatalogService({ + store, + getRecord: () => record, + resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }) + }) + const resting = { child: null, params: { provider: 'claude' } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. + const context = { + deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + return readStructuredAgentSessionOptions(context, SESSION) +} + +function pickerEffort(result: Awaited>) { + const seed = getAgentSessionOptionCatalog('claude')! + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude', seed), + seed, + result + ) + const effort = structuredAgentSessionOptionSnapshot(state).find((row) => row.id === 'effort') + return effort?.kind.type === 'select' ? effort.kind.currentValue : undefined +} + +describe('Claude effort default at rest', () => { + it("learns the model's default from a live child's applied effort", async () => { + const store = new AgentModelCatalogStore() + await startChild(store) + + // Both rows run claude-opus-5-5[1m]; sonnet was not applied, so nothing is known of it. + expect(catalogDefault(store, 'opus[1m]')).toBe('medium') + expect(catalogDefault(store, 'sonnet')).toBeUndefined() + }) + + it('shows that default in the picker of a chat at rest', async () => { + const store = new AgentModelCatalogStore() + await startChild(store) + + const result = await readAtRest(store, restingRecord({ model: 'opus[1m]' })) + + expect(result.current).toMatchObject({ model: 'opus[1m]', effort: 'medium' }) + expect(pickerEffort(result)).toBe('medium') + }) + + it("shows the user's pick at rest over the default", async () => { + const store = new AgentModelCatalogStore() + await startChild(store) + + const result = await readAtRest(store, restingRecord({ model: 'opus[1m]', effort: 'max' })) + + expect(result.current.effort).toBe('max') + expect(pickerEffort(result)).toBe('max') + }) + + it("never learns a user's pick as the default", async () => { + const store = new AgentModelCatalogStore() + const restored = await startChild(store, { model: 'opus[1m]', effort: 'high' }) + await restored.readOptions({ sessionId: SESSION, fence: 7 }) + expect(catalogDefault(store, 'opus[1m]')).toBeUndefined() + + const other = new AgentModelCatalogStore() + const live = await startChild(other) + await live.setOption({ sessionId: SESSION, fence: 7, key: 'effort', value: 'xhigh' }) + expect((await live.readOptions({ sessionId: SESSION, fence: 7 })).current.effort).toBe('xhigh') + expect(catalogDefault(other, 'opus[1m]')).toBe('medium') + }) + + it("leaves a Codex chat's unsaved effort blank at rest, as its live child does", async () => { + const store = new AgentModelCatalogStore() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. + const record = { + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, + location: { wslDistro: null }, + options: { model: 'gpt-5.5' } + } as unknown as AgentSessionRecord + store.recordSuccess(agentModelCatalogFingerprintForRecord(record), 'codex', { + models: [ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ], + fastModeTierByModel: new Map(), + origin: 'live-session' + }) + + const result = await readAtRest(store, record) + + expect(result.current).toEqual({ model: 'gpt-5.5' }) + }) + + it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => { + const store = new AgentModelCatalogStore() + const events: ClaudeStructuredSessionEvent[] = [] + await startChild(store, undefined, events) + + const started = events.find((event) => event.type === 'started') + const reported = started?.type === 'started' ? started.reportedOptions : null + expect(reported).not.toHaveProperty('effort') + // The record the start persists names the listed row the catalog learned under. + const record = restingRecord( + nativeSessionOptionsFromReport({ reported: reported!, restoreSkipped: [] }) + ) + expect(record.options).toEqual({ model: 'opus[1m]' }) + expect((await readAtRest(store, record)).current.effort).toBe('medium') + expect(record.options).toEqual({ model: 'opus[1m]' }) + }) +}) diff --git a/src/main/claude/claude-structured-init-proof.ts b/src/main/claude/claude-structured-init-proof.ts index c29cb2d4715..ae2ee5896a8 100644 --- a/src/main/claude/claude-structured-init-proof.ts +++ b/src/main/claude/claude-structured-init-proof.ts @@ -46,15 +46,25 @@ export function readClaudeModels(initialization: unknown): unknown[] { : [] } -/** CLI capabilities advertised on the initialize result or the yielded system/init frame. */ +/** + * The capabilities this CLI advertises: the first report given that names any, else `observed`. + * A SessionStart proof and the 2.1.280 initialize result name none, so only a turn's system/init + * may say what the binary supports, and a report naming none never retracts that. + */ export function readClaudeCapabilities( - init: ClaudeInitObservation, - initialization: unknown -): string[] { - const fromResult = isRecord(initialization) ? initialization.capabilities : undefined - const fromFrame = init.message.capabilities - const source = Array.isArray(fromResult) ? fromResult : Array.isArray(fromFrame) ? fromFrame : [] - return source.filter((value): value is string => typeof value === 'string') + observed: readonly string[], + ...reports: unknown[] +): readonly string[] { + for (const report of reports) { + const advertised = isRecord(report) ? report.capabilities : undefined + const capabilities = Array.isArray(advertised) + ? advertised.filter((value): value is string => typeof value === 'string') + : [] + if (capabilities.length > 0) { + return capabilities + } + } + return observed } export function claudeInitializationAuthError( @@ -64,7 +74,8 @@ export function claudeInitializationAuthError( isRecord(initialization) && isRecord(initialization.account) ? initialization.account : null return readClaudeFrameString(account ?? {}, 'tokenSource') === 'none' ? new AgentSessionAcquisitionRefusal( - 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.' + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.', + 'notSignedIn' ) : null } diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index c54d1a5179f..3d7592c8406 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -492,16 +492,17 @@ describe('claude structured launch resolution', () => { gate = WSL_ONLY_NORMALIZED - // Reacquire after the account state changed: refused before anything spawns. - await expect(resolve({ identity: identityAt('leaf-current') })).rejects.toBeInstanceOf( - AgentSessionPreSpawnError - ) + // Reacquire after the account state changed: refused before anything spawns, naming the + // account shape a person can change. + const refused = resolve({ identity: identityAt('leaf-current') }) + await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + await expect(refused).rejects.toMatchObject({ reason: 'managedAccountUnsupported' }) }) - it('fails closed when the account state cannot be read', async () => { - await expect( - resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) - ).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + it('fails closed when the account state cannot be read, naming no situation', async () => { + const refused = resolverWithGate(() => null)({ identity: identityAt('leaf-current') }) + await expect(refused).rejects.toBeInstanceOf(AgentSessionPreSpawnError) + await expect(refused).rejects.toMatchObject({ reason: undefined }) }) it('keeps resolving when no gate is wired, so other embedders are unaffected', async () => { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index a2b3d41bbfd..d5d0542cb9d 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -22,6 +22,7 @@ import { } from '../claude-accounts/live-pty-gate' import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { + hasWslBoundClaudeAccount, structuredClaudeMatchesActiveManagedAccount, type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' @@ -172,7 +173,9 @@ export async function resolveClaudeStructuredInvocation( // Under a managed account the pinned credential is the only auth this launch may // use, so an explicit override is refused rather than silently beating the pin. if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) { - throw new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE), { + reason: 'managedAccountEnvOverride' + }) } // Why the overlay merges onto the inherited env rather than replacing it: the child // still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath @@ -209,7 +212,9 @@ export async function assertClaudeAuthSwitchSettled( timeoutMs = CLAUDE_AUTH_SWITCH_SETTLE_TIMEOUT_MS ): Promise { if (!(await whenClaudeAuthSwitchSettles(timeoutMs))) { - throw new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { + reason: 'accountSwitchInProgress' + }) } } @@ -247,12 +252,12 @@ export function createClaudeStructuredLaunchResolver( // Every acquisition, not just the first: the account state can change under a live session, and // a reacquire after an unexpected exit would otherwise spawn under whatever it has become. // Codex has no gate here — it resolves its account on a different path. - if ( - deps.readManagedAccountGate && - !structuredClaudeMatchesActiveManagedAccount(deps.readManagedAccountGate()) - ) { + const gate = deps.readManagedAccountGate?.() + if (gate !== undefined && !structuredClaudeMatchesActiveManagedAccount(gate)) { + // Unreadable account state names no situation a person can act on, so only the log reads it. throw new AgentSessionPreSpawnError( - 'structured Claude is not offered under the active managed Claude account' + 'structured Claude is not offered under the active managed Claude account', + gate && hasWslBoundClaudeAccount(gate) ? { reason: 'managedAccountUnsupported' } : {} ) } const head = agentSessionProviderHandleChainHead(record.providerHandleChain) diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 0090cd2f039..2f0b7ac4b70 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -41,6 +41,13 @@ export function restoredClaudeStructuredSessionOptions( ) } +/** The keys `setClaudeStructuredOption` writes; a pick made at rest is checked against these. */ +const CLAUDE_STRUCTURED_OPTION_KEYS = new Set(['model', 'permissionMode', 'effort', 'fastMode']) + +export function isClaudeStructuredOptionKey(key: string): boolean { + return CLAUDE_STRUCTURED_OPTION_KEYS.has(key) +} + /** A client's write; the startup restore writes through `setClaudeStructuredOption` directly. */ export function setClaudeStructuredSessionOption( session: ClaudeSession, @@ -51,7 +58,8 @@ export function setClaudeStructuredSessionOption( if (session.startup.state !== 'proven') { return Promise.reject( new AgentSessionOptionRejectedError( - 'Claude is still starting; options can be changed once it is ready.' + 'Claude is still starting; options can be changed once it is ready.', + 'providerStarting' ) ) } diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 678c6a4f1e2..3c290fa5184 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -269,7 +269,8 @@ describe('Claude live prompt ownership', () => { sessionId: 'session-1', clientMessageId: 'queued-message', state: 'rejected', - reason: 'provider_cancelled_before_start' + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } }) }) diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index baa6cb83a52..e107633173c 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -16,6 +16,11 @@ import { } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' import { rootClaudeRowStamp, type ClaudeRowStamp } from './claude-provisional-row-corrections' +import { + CLAUDE_API_RETRY_FRAME_KIND, + claudeApiRetryRowBody, + createClaudeApiRetryRuns +} from './claude-api-retry-row' export function claudeProviderFrameKind(message: Record): string { const type = claudeText(message.type) ?? 'unknown' @@ -121,9 +126,23 @@ export function createClaudeProviderFrameFallback( ) => boolean } { let sequence = 0 + const retryRun = createClaudeApiRetryRuns() return { append: (kind, payload, displayText, beforeAppend, options, stamp) => { sequence += 1 + const retrying = kind === CLAUDE_API_RETRY_FRAME_KIND ? claudeRecord(payload) : null + if (retrying) { + beforeAppend?.() + // One row per retry run, revised by each attempt, never the frame as a row. + const identity = { + provider: 'orca', + clientMessageId: `provider-retry:claude:${acquisitionId}:${retryRun(retrying)}` + } as const + const body = claudeApiRetryRowBody(retrying) + sink.appendItem(identity, body, (stamp ?? rootClaudeRowStamp)(identity, body)) + sink.publish() + return true + } const translated = unhandledProviderFrameJournalItem( 'claude', kind, diff --git a/src/main/claude/claude-structured-queued-stop.test.ts b/src/main/claude/claude-structured-queued-stop.test.ts new file mode 100644 index 00000000000..2dbc99bb82b --- /dev/null +++ b/src/main/claude/claude-structured-queued-stop.test.ts @@ -0,0 +1,205 @@ +// A send Claude queued behind the running turn is dropped by Stop, so it must settle as withdrawn. +// Orca's SessionStart hook proves most starts before the turn's system/init, which is the only +// frame that says whether the CLI can cancel its queue. + +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' +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, + adapterFor, + fakeClaude, + identityFor, + type FakeConnection +} from './claude-structured-session-test-support' + +// As Claude Code 2.1.280 advertises them on a turn's system/init frame. +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +type Settlement = Parameters< + NonNullable +>[0] + +/** Answers the way the real CLI does: cancel_queued cancels the queue, a plain interrupt keeps it. */ +function claudeCli(options: Parameters[0] = {}) { + let queued: string[] = [] + const claude = fakeClaude({ + replayUuid: null, + ...options, + routes: { + interrupt: (params) => + params?.cancelQueued + ? { still_queued: [], cancelled: queued.splice(0) } + : { still_queued: [...queued] }, + cancel_async_message: (params) => { + const before = queued.length + queued = queued.filter((uuid) => uuid !== params?.uuid) + return queued.length < before + } + } + }) + return { claude, queue: (uuid: string) => queued.push(uuid) } +} + +function turnInit(capabilities?: string[]): Record { + return { + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION_ID, + uuid: 'turn-init', + model: 'claude-sonnet-5', + ...(capabilities ? { capabilities } : {}) + } +} + +async function acquired( + claude: ReturnType, + settlements: Settlement[] = [] +): Promise<{ + adapter: ReturnType + bodies: Map + connection: FakeConnection +}> { + const bodies = new Map() + const adapter = adapterFor(claude, {}, [], [], undefined, undefined, undefined, (settlement) => + settlements.push(settlement) + ) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: { + appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), + appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), + publish: vi.fn() + } + }) + return { adapter, bodies, connection: claude.connections[0]! } +} + +function runningTurnId(bodies: Map): string { + for (const body of bodies.values()) { + const turn = readAgentJournalTurn(body) + if (turn?.state === 'running') { + return turn.turnId + } + } + throw new Error('expected a running turn') +} + +/** 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') + 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)) + + 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 }) + } finally { + vi.useRealTimers() + } + return { + interrupt: connection.calls.find((call) => call.subtype === 'interrupt')?.params, + settlements: settlements.filter((settlement) => settlement.clientMessageId === 'client-b') + } +} + +const WITHDRAWN = [ + { + sessionId: 'session-1', + clientMessageId: 'client-b', + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } + } +] + +describe('Stop with a follow-up Claude queued behind the running turn', () => { + it('withdraws it the same way whether system/init or a SessionStart hook proved the start', async () => { + const byInit = await stopWithQueuedFollowUp( + claudeCli({ initProof: 'init', capabilities: CAPABILITIES }), + () => {} + ) + const bySessionStart = await stopWithQueuedFollowUp( + claudeCli({ initProof: 'session-start' }), + (connection) => connection.handlers.onMessage?.(turnInit(CAPABILITIES)) + ) + + expect(byInit).toEqual({ interrupt: { cancelQueued: true }, settlements: WITHDRAWN }) + expect(bySessionStart).toEqual(byInit) + }) + + it('keeps what a turn advertised when a later frame names nothing', async () => { + const result = await stopWithQueuedFollowUp( + claudeCli({ initProof: 'session-start' }), + (connection) => { + connection.handlers.onMessage?.(turnInit(CAPABILITIES)) + connection.handlers.onMessage?.(turnInit()) + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'hook_response', + hook_name: 'SessionStart:resume', + session_id: PROVIDER_SESSION_ID + }) + } + ) + + expect(result).toEqual({ interrupt: { cancelQueued: true }, settlements: WITHDRAWN }) + }) + + it('keeps what a turn advertised when the start is read after it', async () => { + const cli = claudeCli({ initProof: 'session-start' }) + const open = cli.claude.openConnection + // The turn's init lands while startup still waits on get_settings, as a send that starts the + // agent allows. + cli.claude.openConnection = async (launch, handlers) => { + const connection = await open(launch, handlers) + const getSettings = connection.getSettings + connection.getSettings = async (options) => { + handlers?.onMessage?.(turnInit(CAPABILITIES)) + return getSettings(options) + } + return connection + } + + const result = await stopWithQueuedFollowUp(cli, () => {}) + + expect(result).toEqual({ interrupt: { cancelQueued: true }, settlements: WITHDRAWN }) + }) + + it('settles a follow-up withdrawn one at a time on a CLI without cancel_queued', async () => { + const result = await stopWithQueuedFollowUp( + claudeCli({ initProof: 'init', capabilities: ['interrupt_receipt_v1'] }), + () => {} + ) + + expect(result).toEqual({ interrupt: {}, settlements: WITHDRAWN }) + }) +}) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index c117e3e863f..cfd4c8c33ea 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -13,6 +13,7 @@ import { ClaudeStructuredSessionAdapter, type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' +import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' const command = resolveClaudeCommand() const versionLaunch = getSpawnArgsForWindows(command, ['--version']) @@ -49,7 +50,8 @@ function realAdapter( providerSessionId: string, claudeConfigDir: string, events: ClaudeStructuredSessionEvent[] = [], - cwd = process.cwd() + cwd = process.cwd(), + onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] ): ClaudeStructuredSessionAdapter { const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -63,6 +65,7 @@ function realAdapter( continuesChain: false }), onEvent: (event) => events.push(event), + ...(onDispatchSettledLate ? { onDispatchSettledLate } : {}), readProcessStartTime: async () => 1, now: () => 2 }) @@ -302,6 +305,87 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI handshake', () 90_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)( + 'withdraws a follow-up queued behind a turn that is stopped, behind a SessionStart hook', + async () => { + const providerSessionId = randomUUID() + const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + const cwd = await mkdtemp(join(tmpdir(), 'orca-queued-stop-')) + await mkdir(join(cwd, '.claude'), { recursive: true }) + await writeFile( + join(cwd, '.claude', 'settings.json'), + JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'true' }] }] } + }) + ) + const events: ClaudeStructuredSessionEvent[] = [] + const settlements: unknown[] = [] + const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd, (settlement) => + settlements.push(settlement) + ) + const send = (clientMessageId: string, text: string) => + adapter.dispatch({ + sessionId: 'real-cli-handshake', + clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, + fence: 1 + }) + const waitFor = async (found: () => boolean): Promise => { + const deadline = Date.now() + 60_000 + while (!found() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return found() + } + + try { + await adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'real-cli-queued-stop' + }) + await send('real-cli-queued-stop-a', 'Count from 1 to 400, one number per line.') + // A's reply is streaming, so the next send queues behind its turn. + await expect( + waitFor(() => + events.some( + (event) => + event.type === 'message' && + event.message.type === 'stream_event' && + JSON.stringify(event.message).includes('text_delta') + ) + ) + ).resolves.toBe(true) + await send('real-cli-queued-stop-b', 'Say the word banana.') + + await expect( + adapter.cancelTurn({ + sessionId: 'real-cli-handshake', + turnId: 'turn-a', + fence: 1, + resolveLiveTurnId: () => 'turn-a', + // What the host reads for B: handed over, not yet answered. + dispatchStatus: { state: 'pending', recovered: false } + }) + ).resolves.toEqual({ cancelled: true }) + + expect(settlements).toContainEqual({ + sessionId: 'real-cli-handshake', + clientMessageId: 'real-cli-queued-stop-b', + state: 'rejected', + reason: 'provider_cancelled_before_start', + rejection: { kind: 'cancelled' } + }) + } finally { + await adapter.closeAll() + await rm(cwd, { recursive: true, force: true }) + } + }, + 150_000 + ) + it('turns a real silent unauthenticated startup into sign-in guidance', async () => { const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) const providerSessionId = randomUUID() diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts index 85de77bbbd8..eb4babdb9ac 100644 --- a/src/main/claude/claude-structured-session-acquisition-processless.test.ts +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -37,7 +37,7 @@ describe('Claude structured processless acquisition', () => { getContextUsage: async () => ({}), supportedModels: async () => [], interrupt: async () => undefined, - cancelAsyncMessage: async () => {}, + cancelAsyncMessage: async () => false, setModel: async () => {}, setPermissionMode: async () => {}, applyFlagSettings: async () => {}, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 6728f960ef0..ac8d65ba107 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -8,7 +8,11 @@ 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-structured-dispatch' -import { readClaudeFrameString, readClaudeInit } from './claude-structured-init-proof' +import { + readClaudeCapabilities, + readClaudeFrameString, + readClaudeInit +} from './claude-structured-init-proof' import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { CLAUDE_SPAWN_TOKEN_ENV, claudeProcessIdentity } from './claude-structured-owner-identity' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' @@ -30,6 +34,7 @@ import { type ClaudeAcquireCallbacks } from './claude-structured-session-state' import { resolveClaudeAcquisitionError } from './claude-structured-session-close' +import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' import { persistClaudeTurnResumePoint } from './claude-structured-resume-point' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' @@ -58,7 +63,9 @@ export async function acquireClaudeSession({ // A managed-account switch is mid-swap of the pinned credential home; refuse here, // before this acquisition cancels the previous attempt and closes the live session. if (isClaudeAuthSwitchInProgress()) { - throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE)) + throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE), { + reason: 'accountSwitchInProgress' + }) } const sessionId = input.identity.sessionId const prompts = new ClaudePromptRegistry() @@ -98,6 +105,9 @@ export async function acquireClaudeSession({ liveSession.reportedOptions.model = init.model liveSession.reportedModelMutation = liveSession.optionMutationSequence } + if (liveSession) { + liveSession.capabilities = readClaudeCapabilities(liveSession.capabilities, init.message) + } } observedLeafUuid = readClaudeTranscriptEntryUuid(message) ?? observedLeafUuid if (liveSession) { @@ -178,6 +188,8 @@ export async function acquireClaudeSession({ initProof.reject(error) }, onExit: (error) => { + // The child exited on its own; marked in place, as the fault report may hold this error. + withObservedProviderExit(error) childEnded ??= error initProof.reject(error) callbacks.handleExit(sessionId, attempt, error) 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 caafee6a94b..c77d5ee0a91 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -68,7 +68,11 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toEqual({ state: 'rejected', reason: 'provider_write_failed: broken pipe' }) + ).resolves.toEqual({ + state: 'rejected', + reason: 'provider_write_failed', + rejection: { kind: 'writeFailed' } + }) }) it('requires an acknowledged interrupt and supports controlled options', async () => { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 5234606a1cc..29b51e730ae 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,3 +1,4 @@ +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import { compactClaudeSession, observeClaudeCompaction } from './claude-structured-compaction' import type { AgentSessionAcquisition, @@ -13,7 +14,7 @@ import { supportsClaudeStructuredLocation } from './claude-structured-location-s import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { - claudeStartupFailureReason, + claudeStartupFailureFact, claudeStartupSettledWithin } from './claude-structured-session-startup-state' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' @@ -120,13 +121,13 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda /** Resolves once a published session's startup has landed, faulted, or been ended by a close; * with the reason when it did not land. */ - awaitStarted = async (sessionId: string): Promise => { + awaitStarted = async (sessionId: string): Promise => { const session = this.sessions.get(sessionId) if (!session) { return } await session.startup.settled - return claudeStartupFailureReason(session) ?? undefined + return claudeStartupFailureFact(session) ?? undefined } /** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */ @@ -244,7 +245,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - recordsContextUsage = (sessionId: string): boolean => this.sessions.has(sessionId) + // Provider-level: a session at rest still reports the usage its journal recorded. + recordsContextUsage = (): boolean => true readOptionRestoreFailures = (sessionId: string): readonly string[] => [ ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) diff --git a/src/main/claude/claude-structured-session-exit-blame.test.ts b/src/main/claude/claude-structured-session-exit-blame.test.ts new file mode 100644 index 00000000000..79430131e15 --- /dev/null +++ b/src/main/claude/claude-structured-session-exit-blame.test.ts @@ -0,0 +1,66 @@ +// After a Claude start lands, only the child's own exit says Claude stopped; a fault on Orca's +// side that makes Orca close the child is Orca's. + +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' +import { + adapterFor, + fakeClaude, + identityFor, + PROVIDER_SESSION_ID +} from './claude-structured-session-test-support' + +async function startedClaude(sink: StructuredAgentSessionEventSink) { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + const ended = async () => { + await vi.waitFor(() => expect(events.some((event) => event.type === 'ended')).toBe(true)) + return events.find((event) => event.type === 'ended') + } + return { connection: claude.connections[0], ended } +} + +describe('what a started Claude session says ended it', () => { + it('says Claude stopped when the child exits on its own', async () => { + const { connection, ended } = await startedClaude({ + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + }) + + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): killed')) + + expect(await ended()).toMatchObject({ + cause: 'unexpected-exit', + failure: { kind: 'providerExited' } + }) + }) + + it('blames Orca when a journal fault makes Orca close the child', async () => { + const { connection, ended } = await startedClaude({ + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendResolvedItemAndPublish: () => ({ accepted: false, reason: 'failed' }) + }) + + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'task_notification', + session_id: PROVIDER_SESSION_ID, + task_id: 'task-1', + status: 'failed', + summary: 'failed' + }) + + expect(await ended()).toMatchObject({ + reason: 'claude background task journal sink failed', + cause: 'unexpected-exit', + failure: { kind: 'hostFault' } + }) + expect(connection.closeCount).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index d0bddaf0a11..854646490a8 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -1,3 +1,8 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' +import { + providerExitObserved, + providerStartupFailureFact +} from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { claudeRootExitObserved, @@ -32,6 +37,8 @@ export function observeClaudeSessionExit( return } lifecycle.sessions.delete(sessionId) + // Not marked here: startup and journal faults end the session this way too. The child's own + // exit arrives already marked by the connection's exit callback. failClaudeStartup(session, error) // Re-enter the provider's close ladder before publishing lifecycle recovery. // An exit callback is root evidence only; the retained tree proof must run @@ -85,6 +92,16 @@ export function settleClaudeUnexpectedExit( type: 'ended', sessionId, reason: exit.error.message, + // A start that never landed says why it failed. After it landed, only the child's own exit + // blames the provider; an Orca fault that closed it is Orca's. + failure: + exit.session.startup.state !== 'proven' + ? providerStartupFailureFact(exit.session.startup.failure ?? exit.error) + : providerExitObserved(exit.error) + ? agentSessionFailureFact('providerExited', { + detail: providerDiagnosticOf(exit.error) + }) + : agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index f50923162e5..f4c59148241 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -223,15 +223,37 @@ export function claudeCatalogAdmitsModel(models: readonly ListedModel[], modelId ) } -function wireClaudeModels(models: readonly ListedModel[]): AgentSessionOptionsResult['models'] { - return models.map((entry) => ({ +type WireClaudeModel = AgentSessionOptionsResult['models'][number] + +function wireClaudeModel(entry: ListedModel): WireClaudeModel { + return { id: entry.id, label: entry.label, ...(entry.description ? { description: entry.description } : {}), isDefault: entry.isDefault, efforts: entry.efforts, ...(entry.supportsFastMode !== undefined ? { supportsFastMode: entry.supportsFastMode } : {}) - })) + } +} + +function wireClaudeModels(models: readonly ListedModel[]): WireClaudeModel[] { + return models.map(wireClaudeModel) +} + +/** The listing, with what the CLI runs when no effort is sent on each model the child applies — + * a default only a running child knows, and only while this session has no effort pick. */ +function catalogClaudeModels(session: ClaudeSession, discovered: ListedModel[]): WireClaudeModel[] { + const applied = session.options.has('effort') ? undefined : session.appliedOptions + return discovered.map((listed) => { + const model = wireClaudeModel(listed) + const effort = applied?.effort + const runsApplied = + applied?.model !== undefined && + (listed.id === applied.model || listed.resolvedModel === applied.model) + return effort && runsApplied && model.efforts.some((choice) => choice.value === effort) + ? { ...model, defaultEffort: effort } + : model + }) } /** Write a provider-listed catalog through to the host store. Account-level @@ -243,7 +265,7 @@ function writeClaudeCatalogThrough(session: ClaudeSession, discovered: ListedMod } const support = claudeFastModeSupport(discovered, undefined) session.catalogAccess.store.recordSuccess(session.catalogAccess.fingerprint, 'claude', { - models: wireClaudeModels(discovered), + models: catalogClaudeModels(session, discovered), ...(support ? { fastModeSupport: support } : {}), fastModeTierByModel: new Map(), origin: 'live-session' diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 8ddc29a2e67..a5647caee84 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -417,6 +417,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { type: 'ended', sessionId: 'session-1', reason: 'crashed before replacement', + failure: { kind: 'providerExited' }, cause: 'unexpected-exit', fence: 7, acquisitionGeneration: firstAcquisition.acquisitionGeneration, diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index 1bda560377a..cf44bd984ab 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -3,7 +3,8 @@ // options restored): the host's delivery loop waits on `settled` before it hands a message over, // so a first turn never runs under defaults the restore was about to replace. -import { providerStartupFailureRejection } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' +import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import type { ClaudeSession } from './claude-structured-session-state' export type ClaudeSessionStartup = { @@ -24,9 +25,9 @@ export function createClaudeSessionStartup(): ClaudeSessionStartup { return { state: 'pending', failure: null, settled: ended, end } } -export function claudeStartupFailureReason(session: ClaudeSession): string | null { +export function claudeStartupFailureFact(session: ClaudeSession): SubmissionRejectionFact | null { return session.startup.state === 'failed' - ? providerStartupFailureRejection(session.startup.failure ?? undefined) + ? providerStartupFailureFact(session.startup.failure ?? undefined) : null } diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index d486a670a13..ce14a64dd2c 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -138,7 +138,7 @@ function applyClaudeStartupFacts(session: ClaudeSession, facts: ClaudeStartupFac session.fastModeState ??= published.fastModeState session.fastModeDisabledReason ??= published.fastModeDisabledReason session.options = prepared.options - session.capabilities = readClaudeCapabilities(init, initialization) + session.capabilities = readClaudeCapabilities(session.capabilities, initialization, init.message) // A catalog frame that streamed in after publish is newer than the initialize answer. if (session.commands.commands === undefined) { session.commands = new ClaudeSlashCommandCatalog(init.message, initialization) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index b671dd8b632..a0b0f321679 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -1,3 +1,5 @@ +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity @@ -66,6 +68,7 @@ export type ClaudeStructuredSessionEvent = type: 'ended' sessionId: string reason: string + failure?: SubmissionRejectionFact /** Present for first-hand child exits so the host can fence recovery. */ cause?: 'unexpected-exit' | 'requested-close' fence?: number @@ -81,7 +84,7 @@ export type ClaudeLateDispatchOutcome = clientMessageId: string providerIdentity: AgentJournalItemIdentity } - | { clientMessageId: string; state: 'rejected'; reason: string } + | ({ clientMessageId: string; state: 'rejected' } & AgentJournalDispatchRejection) export type ClaudeStructuredSessionAdapterDeps = { resolveLaunch: (input: { diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index c8e3d86b0e8..51c7c1c8fff 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -173,7 +173,7 @@ export function fakeClaude( }, cancelAsyncMessage: async (uuid) => { connection.calls.push({ subtype: 'cancel_async_message', params: { uuid } }) - routed('cancel_async_message', { uuid }) + return routed('cancel_async_message', { uuid }) === true }, stopTask: async (taskId) => { connection.calls.push({ subtype: 'stop_task', params: { taskId } }) diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts index 2c54cc8db62..225a52423c3 100644 --- a/src/main/claude/compact-status-registration.test.ts +++ b/src/main/claude/compact-status-registration.test.ts @@ -12,7 +12,9 @@ import { import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { makePaneKey } from '../../shared/stable-pane-id' -import { applyManagedHooks, CLAUDE_EVENTS } from './hook-settings' +import { CLAUDE_HOOK_EVENT_FIRST_VERSIONS } from './claude-hook-event-versions' +import { CLAUDE_EVENTS, getClaudeManagedHookPlan } from './claude-managed-hook-events' +import { applyManagedHooks } from './hook-settings' const PANE_KEY = makePaneKey('compact-registration', '11111111-1111-4111-8111-111111111111') const TURN_PROMPT_ID = '22222222-2222-4222-8222-222222222222' @@ -108,7 +110,8 @@ describe('Claude compact hook registration', () => { const written = applyManagedHooks( { hooks: {} }, { type: 'command', command: 'orca-claude-hook' }, - 'claude-hook.sh' + 'claude-hook.sh', + getClaudeManagedHookPlan(CLAUDE_HOOK_EVENT_FIRST_VERSIONS.PostCompact) ) const postCompact = written.hooks?.PostCompact ?? [] expect( diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts index efbe71fc9e3..e02fe374701 100644 --- a/src/main/claude/hook-script.ts +++ b/src/main/claude/hook-script.ts @@ -1,6 +1,7 @@ /** The managed Claude-compatible hook script, built for local, POSIX-remote and Windows targets. * Split from hook-service.ts so the service owns install/status and this owns script text, * mirroring the same split under src/main/cursor/. */ +import type { AgentHookSource } from '../../shared/agent-hook-relay' import { buildWindowsAgentHookCurlPostCommand } from '../agent-hooks/installer-utils' import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { @@ -18,10 +19,12 @@ import { export function getManagedScript( target: 'local' | 'posix' = 'local', options: { + source?: AgentHookSource skipWhenDevinImportsClaude?: boolean skipWhenGrokImportsClaude?: boolean } = {} ): string { + const source = options.source ?? 'claude' if (target === 'local' && process.platform === 'win32') { return [ '@echo off', @@ -46,7 +49,7 @@ export function getManagedScript( ] : []), // Why: use curl.exe to avoid an extra PowerShell startup per hook. - buildWindowsAgentHookCurlPostCommand('claude'), + buildWindowsAgentHookCurlPostCommand(source), 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), '' @@ -59,7 +62,7 @@ export function getManagedScript( 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), ...(options.skipWhenGrokImportsClaude ? buildPosixGrokReplayGuardLines() : []), - ...buildPosixHookSpoolLines('claude'), + ...buildPosixHookSpoolLines(source), ...(options.skipWhenDevinImportsClaude ? [ // Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin. @@ -84,7 +87,7 @@ export function getManagedScript( ' exit 0', 'fi', // Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths. - ...buildPosixAgentHookPostCommand('claude').map((line, index, lines) => + ...buildPosixAgentHookPostCommand(source).map((line, index, lines) => index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line ), 'exit 0', diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 2a07aff4c8a..c9191d42dbd 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -27,11 +27,8 @@ import type { SFTPWrapper } from 'ssh2' import { createManagedCommandMatcher, WINDOWS_CMD_SAFE_PATH } from '../agent-hooks/installer-utils' import { WINDOWS_HOOK_STDIN_DRAIN_LABEL } from '../agent-hooks/hook-stdin-contract' import { ClaudeHookService } from './hook-service' -import { - CLAUDE_EVENTS, - getWindowsManagedLifecycleHook, - OPENCLAUDE_HOOK_SETTINGS -} from './hook-settings' +import { CLAUDE_EVENTS } from './claude-managed-hook-events' +import { getWindowsManagedLifecycleHook, OPENCLAUDE_HOOK_SETTINGS } from './hook-settings' const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' const STATUSLINE_SCRIPT_FILE_NAME = @@ -43,6 +40,9 @@ const isOpenClaudeManagedCommand = createManagedCommandMatcher(OPENCLAUDE_SCRIPT type TestHook = { command: string; args?: string[] } +// Why: the managed event set follows the resolved Claude; this one knows every event Orca writes. +const CURRENT_CLAUDE = { claudeVersion: '2.1.261 (Claude Code)' } + function hasManagedCommand(hook: TestHook, matcher: (command: string | undefined) => boolean) { return matcher(hook.command) || hook.args?.some(matcher) === true } @@ -217,7 +217,7 @@ describe('ClaudeHookService.install', () => { }) ) - const status = new ClaudeHookService().install() + const status = new ClaudeHookService().install(CURRENT_CLAUDE) expect(status.state).toBe('installed') const legacy = JSON.parse(readFileSync(legacyPath, 'utf-8')) @@ -275,6 +275,48 @@ describe('ClaudeHookService.install', () => { } }) + it('writes only the events an old resolved Claude knows and reports that as installed', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-old-version-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + const service = new ClaudeHookService() + expect(service.install({ claudeVersion: '2.1.32' }).state).toBe('installed') + + const settings = JSON.parse(readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')) + for (const event of ['StopFailure', 'PostCompact', 'TeammateIdle', 'SessionEnd']) { + expect(settings.hooks[event], event).toBeUndefined() + } + expect(settings.hooks.SubagentStart).toBeDefined() + // Why: a reader without the version still sees a complete install of the universal set. + expect(service.getStatus().state).toBe('installed') + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + + it('keeps newer Orca events and reports installed when a later probe cannot resolve the version', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-unknown-version-')) + vi.stubEnv('HOME', tmpHome) + vi.stubEnv('USERPROFILE', tmpHome) + try { + const service = new ClaudeHookService() + const settingsPath = join(tmpHome, '.claude', 'settings.json') + expect(service.install(CURRENT_CLAUDE).state).toBe('installed') + const known = JSON.parse(readFileSync(settingsPath, 'utf-8')) + + expect(service.install().state).toBe('installed') + + const unknown = JSON.parse(readFileSync(settingsPath, 'utf-8')) + expect(unknown.hooks).toEqual(known.hooks) + expect(service.getStatus().state).toBe('installed') + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) + it('installs the managed statusLine command and forwards rate_limits posts', () => { const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-')) vi.stubEnv('HOME', tmpHome) @@ -458,7 +500,7 @@ describe('ClaudeHookService.install', () => { return } try { - expect(new ClaudeHookService().install().state).toBe('installed') + expect(new ClaudeHookService().install(CURRENT_CLAUDE).state).toBe('installed') const settings = JSON.parse( readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8') @@ -472,7 +514,7 @@ describe('ClaudeHookService.install', () => { } // Why: the whole point of #18875 — no interpreter is started to reach the script. expect(JSON.stringify(settings.hooks)).not.toMatch(/powershell|EncodedCommand/i) - expect(new ClaudeHookService().getStatus().state).toBe('installed') + expect(new ClaudeHookService().getStatus(CURRENT_CLAUDE).state).toBe('installed') } finally { vi.unstubAllEnvs() rmSync(tmpHome, { recursive: true, force: true }) @@ -681,7 +723,7 @@ describe('ClaudeHookService.installRemote', () => { it('writes Claude settings + managed script under the remote $HOME', async () => { const svc = new ClaudeHookService() const { sftp, fs } = createFakeSftp() - const status = await svc.installRemote(sftp, '/home/dev') + const status = await svc.installRemote(sftp, '/home/dev', CURRENT_CLAUDE) expect(status.state).toBe('installed') expect(status.configPath).toBe('/home/dev/.claude/settings.json') const settings = fs.files.get('/home/dev/.claude/settings.json') @@ -732,6 +774,25 @@ describe('ClaudeHookService.installRemote', () => { expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-statusline.sh')).toBeUndefined() }) + it('writes only the events the remote Claude knows, adding newer ones once it upgrades', async () => { + const svc = new ClaudeHookService() + const { sftp, fs } = createFakeSftp() + const settingsPath = '/home/dev/.claude/settings.json' + const userStop = { hooks: [{ type: 'command', command: '/usr/local/bin/my-user-hook' }] } + fs.files.set(settingsPath, JSON.stringify({ env: { A: '1' }, hooks: { Stop: [userStop] } })) + + await svc.installRemote(sftp, '/home/dev', { claudeVersion: '2.1.77' }) + const old = JSON.parse(fs.files.get(settingsPath)!) + expect(old.hooks.StopFailure).toBeUndefined() + expect(old.hooks.PostCompact).toBeDefined() + + await svc.installRemote(sftp, '/home/dev', { claudeVersion: '2.1.78' }) + const upgraded = JSON.parse(fs.files.get(settingsPath)!) + expect(upgraded.hooks.StopFailure[0].hooks[0].command).toContain('claude-hook.sh') + expect(upgraded.hooks.Stop[0]).toEqual(userStop) + expect(upgraded.env).toEqual({ A: '1' }) + }) + it('reports parse error when remote settings.json cannot be parsed', async () => { const svc = new ClaudeHookService() const { sftp, fs } = createFakeSftp() diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 73e7add40dc..bc27b56075e 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -1,4 +1,5 @@ import { existsSync, rmSync, writeFileSync } from 'node:fs' +import type { AgentHookSource } from '../../shared/agent-hook-relay' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { @@ -21,7 +22,6 @@ import { getManagedStatusLineScript } from './statusline-script' import { applyManagedHooks, applyManagedStatusLine, - CLAUDE_EVENTS, CLAUDE_HOOK_SETTINGS, getManagedScriptFileName, getConfigPath, @@ -40,11 +40,19 @@ import { removeManagedStatusLine, type ClaudeCompatibleHookSettings } from './hook-settings' +import { + getClaudeManagedHookPlan, + OPENCLAUDE_MANAGED_HOOK_PLAN, + type ClaudeManagedHookPlan +} from './claude-managed-hook-events' type ClaudeHookServiceOptions = { agent: AgentHookInstallStatus['agent'] displayName: string settings: ClaudeCompatibleHookSettings + source?: AgentHookSource + /** A Claude-compatible CLI with its own settings file writes a fixed plan, not Claude's version table. */ + hookPlan?: ClaudeManagedHookPlan } type ClaudeHookInstallOptions = { @@ -64,7 +72,15 @@ export class ClaudeHookService { this.options = options } - getStatus(): AgentHookInstallStatus { + // Why: Claude's settings loader rejects events newer than the running CLI, so its plan follows the + // resolved version; OpenClaude and Qoder read their own settings files. + private managedHookPlan(options: ClaudeHookInstallOptions): ClaudeManagedHookPlan { + return this.options.agent === 'claude' + ? getClaudeManagedHookPlan(options.claudeVersion) + : (this.options.hookPlan ?? OPENCLAUDE_MANAGED_HOOK_PLAN) + } + + getStatus(options: ClaudeHookInstallOptions = {}): AgentHookInstallStatus { const configPath = getConfigPath(this.options.settings) const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) @@ -82,7 +98,7 @@ export class ClaudeHookService { const expectedHook = getManagedLifecycleHook(scriptPath, this.options.settings) const missing: string[] = [] let presentCount = 0 - for (const event of CLAUDE_EVENTS) { + for (const event of this.managedHookPlan(options).install) { const definitions = Array.isArray(config.hooks?.[event.eventName]) ? config.hooks![event.eventName]! : [] @@ -115,6 +131,7 @@ export class ClaudeHookService { await refreshManagedScriptIfPresent( getManagedScriptPath(this.options.settings), getManagedScript('local', { + source: this.options.source, skipWhenDevinImportsClaude: this.options.agent === 'claude', skipWhenGrokImportsClaude: this.options.agent === 'claude' }) @@ -141,25 +158,28 @@ export class ClaudeHookService { } const hook = getManagedLifecycleHook(scriptPath, this.options.settings) + const plan = this.managedHookPlan(options) let nextConfig = applyManagedHooks( config, hook, getManagedScriptFileName(this.options.settings), - this.options.agent === 'claude' ? options : undefined + plan ) writeManagedScript( scriptPath, getManagedScript('local', { + source: this.options.source, skipWhenDevinImportsClaude: this.options.agent === 'claude', skipWhenGrokImportsClaude: this.options.agent === 'claude' }) ) - // Why: the statusline usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider. - if (this.options.agent === 'claude') { + if (plan.statusLine === 'install') { nextConfig = this.installManagedStatusLine(nextConfig) + } else if (plan.statusLine === 'retire') { + nextConfig = this.retireManagedStatusLine(nextConfig) } writeHooksJson(configPath, nextConfig) - return this.getStatus() + return this.getStatus(options) } // Why: the statusline feed is opportunistic (usage display, not agent status); a user who deleted the @@ -186,6 +206,23 @@ export class ClaudeHookService { return next } + // Why: a Claude that predates statusLine discards the whole settings file over Orca's; dropping the + // marker with it keeps an upgrade from reading the removal as the user's opt-out. + private retireManagedStatusLine(config: HooksConfig): HooksConfig { + const { config: next, changed } = removeManagedStatusLine( + config, + getStatusLineScriptFileName(this.options.settings) + ) + if (changed) { + try { + rmSync(getStatusLineInstallMarkerPath(this.options.settings), { force: true }) + } catch { + // Best-effort: a stale marker only means one upgrade skips re-adding the statusline. + } + } + return next + } + // Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred). async installRemote( sftp: SFTPWrapper, @@ -215,7 +252,7 @@ export class ClaudeHookService { config, hook, remoteScriptFileName, - this.options.agent === 'claude' ? options : undefined + this.managedHookPlan(options) ) // Why: write scripts before settings to avoid settings pointing to missing scripts. @@ -224,6 +261,7 @@ export class ClaudeHookService { sftp, remoteScriptPath, getManagedScript('posix', { + source: this.options.source, skipWhenDevinImportsClaude: this.options.agent === 'claude', skipWhenGrokImportsClaude: this.options.agent === 'claude' }) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 42a986cad20..6ce4ea4869c 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -16,12 +16,13 @@ import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { isGitBashAvailable } from '../git-bash' -import { claudeVersionSupportsSessionEnd } from './claude-session-end-hook-capability' +import type { ClaudeManagedHookPlan } from './claude-managed-hook-events' export type ClaudeCompatibleHookSettings = { - configDirName: '.claude' | '.openclaude' - scriptBaseName: 'claude-hook' | 'openclaude-hook' + configDirName: '.claude' | '.openclaude' | '.qoder' + scriptBaseName: 'claude-hook' | 'openclaude-hook' | 'qoder-hook' usesWindowsCompatLauncher: boolean + windowsHookShell?: 'powershell' } export const CLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { @@ -36,81 +37,6 @@ export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { usesWindowsCompatLauncher: false } -export const CLAUDE_EVENTS = [ - // Why: SessionStart is the only event a resumed/idle session emits before the - // first prompt; without it the sidebar row can't exist until the user types (STA-3386). - { - eventName: 'SessionStart', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'UserPromptSubmit', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'Stop', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - // Why: OpenClaude skips normal Stop hooks after API/model errors and emits - // StopFailure instead; without this hook Orca leaves the turn spinning. - { - eventName: 'StopFailure', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - // Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps - // a pane 'working' while background children outlive the lead's turn. - // TeammateIdle parks turn-based teammates without trusting their permanently - // "running" background_tasks entry to gate the pane. - // Older Claude builds ignore unregistered event names (StopFailure precedent). - { - eventName: 'SubagentStart', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'SubagentStop', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'TeammateIdle', - definition: { hooks: [{ type: 'command', command: '' }] } - }, - // Why: PreToolUse gives the dashboard a live readout of the in-flight tool - // (name + input preview) before it completes. - { - eventName: 'PreToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PostToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PostToolUseFailure', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PermissionRequest', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - // Why: a manual /compact ends at an idle prompt without emitting Stop, so PostCompact is the only - // signal that can clear the pane (STA-2915). PreCompact is deliberately NOT registered: it fires - // before the compact is validated, and an aborted compact emits it alone — mapping it to 'working' - // would strand the pane exactly as this registration is meant to prevent (STA-4613). - { - eventName: 'PostCompact', - definition: { hooks: [{ type: 'command', command: '' }] } - } -] as const - -const CLAUDE_SESSION_END_EVENT = { - eventName: 'SessionEnd', - definition: { hooks: [{ type: 'command', command: '' }] } -} as const - -export type ApplyManagedClaudeHooksOptions = { - claudeVersion?: string -} - export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { return join(homedir(), settings.configDirName, 'settings.json') } @@ -171,6 +97,14 @@ export function getManagedLifecycleHook( if (process.platform !== 'win32' || !settings.usesWindowsCompatLauncher) { return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true })) } + if (settings.windowsHookShell === 'powershell') { + return { + type: 'command', + command: getWindowsPowerShellLifecycleCommand(scriptPath), + shell: 'powershell', + timeout: MANAGED_HOOK_TIMEOUT_SECONDS + } + } return getWindowsManagedLifecycleHook(scriptPath, options) } @@ -190,19 +124,21 @@ export function getWindowsManagedLifecycleHook( if (directCommand) { return { type: 'command', command: directCommand, timeout: MANAGED_HOOK_TIMEOUT_SECONDS } } + return { + type: 'command', + command: wrapWindowsPowerShellEncodedCommand(getWindowsPowerShellLifecycleCommand(scriptPath)), + timeout: MANAGED_HOOK_TIMEOUT_SECONDS + } +} + +function getWindowsPowerShellLifecycleCommand(scriptPath: string): string { const scriptFileName = win32.basename(scriptPath) - // Why: runtime profile resolution keeps the managed entry portable across users (STA-3348). const quotedRelativePath = quotePowerShellLiteral(`.orca\\agent-hooks\\${scriptFileName}`) - // Why: compat consumers require neutral JSON even when the managed script is missing (#14818). - const innerCommand = + return ( `$scriptPath = Join-Path $env:USERPROFILE ${quotedRelativePath}; ` + 'if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; ' + "[Console]::In.ReadToEnd() | Out-Null; Write-Output '{}'; exit 0" - return { - type: 'command', - command: wrapWindowsPowerShellEncodedCommand(innerCommand), - timeout: MANAGED_HOOK_TIMEOUT_SECONDS - } + ) } export function hasSameManagedHookInvocation( @@ -211,6 +147,7 @@ export function hasSameManagedHookInvocation( ): boolean { return ( actual.command === expected.command && + actual.shell === expected.shell && JSON.stringify(actual.args ?? []) === JSON.stringify(expected.args ?? []) ) } @@ -222,31 +159,29 @@ export function getRemoteManagedCommand(scriptPath: string): string { export function applyManagedHooks( config: HooksConfig, hook: HookCommandConfig, - scriptFileName = getManagedScriptFileName(), - options: ApplyManagedClaudeHooksOptions = {} + scriptFileName: string, + plan: ClaudeManagedHookPlan ): HooksConfig { const nextHooks = { ...config.hooks } const isManagedCommand = createManagedCommandMatcher(scriptFileName) - const sessionEndCapable = claudeVersionSupportsSessionEnd(options.claudeVersion) - const events = sessionEndCapable ? [...CLAUDE_EVENTS, CLAUDE_SESSION_END_EVENT] : CLAUDE_EVENTS - for (const event of events) { - const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] - const cleaned = removeManagedCommands(current, isManagedCommand) - const definition: HookDefinition = { - ...event.definition, - hooks: [hook] - } + for (const event of plan.install) { + const current = nextHooks[event.eventName] + const cleaned = Array.isArray(current) ? removeManagedCommands(current, isManagedCommand) : [] + const definition: HookDefinition = { ...event.definition, hooks: [hook] } nextHooks[event.eventName] = [...cleaned, definition] } - if (!sessionEndCapable) { - const current = Array.isArray(nextHooks.SessionEnd) ? nextHooks.SessionEnd : [] + for (const event of plan.retire) { + const current = nextHooks[event.eventName] + if (!Array.isArray(current) || current.length === 0) { + continue + } const cleaned = removeManagedCommands(current, isManagedCommand) if (cleaned.length === 0) { - delete nextHooks.SessionEnd + delete nextHooks[event.eventName] } else { - nextHooks.SessionEnd = cleaned + nextHooks[event.eventName] = cleaned } } diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index aa5a4474235..4e7cd016bd6 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { PassThrough } from 'node:stream' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' import { afterEach, describe, expect, it, vi } from 'vitest' import type { spawnProcess } from '../../shared/child-process/run-process' import { @@ -293,6 +294,9 @@ describe('openCodexAppServerConnection', () => { expect(isCodexAppServerRequestError(refusal)).toBe(true) expect((refusal as Error).message).toContain('bad params') + // Codex's own words, apart from Orca's prefix, for a person to read. + expect(providerDiagnosticOf(refusal)).toEqual({ text: 'bad params', audience: 'person' }) + expect(providerDiagnosticOf(missing)).toBeUndefined() expect(isCodexAppServerUnsupportedError(missing)).toBe(true) expect(isCodexAppServerRequestError(missing)).toBe(false) await connection.close() diff --git a/src/main/codex/codex-app-server-exit-error.test.ts b/src/main/codex/codex-app-server-exit-error.test.ts new file mode 100644 index 00000000000..63ec9a63743 --- /dev/null +++ b/src/main/codex/codex-app-server-exit-error.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { providerDiagnosticOf } from '../../shared/agent-session-failure' +import { buildCodexAppServerExitError } from './codex-app-server-exit-error' + +describe('buildCodexAppServerExitError', () => { + it("keeps the stderr tail apart from Orca's wording, as log text", () => { + const error = buildCodexAppServerExitError(' thread panicked at main.rs:4 ') + expect(error.message).toBe('codex app-server connection ended: thread panicked at main.rs:4') + expect(providerDiagnosticOf(error)).toEqual({ + text: 'thread panicked at main.rs:4', + audience: 'log' + }) + }) + + it('carries no diagnostic when a cause, not the stderr, explains the end', () => { + const error = buildCodexAppServerExitError('ignored', new Error('spawn codex ENOENT')) + expect(providerDiagnosticOf(error)).toBeUndefined() + expect(providerDiagnosticOf(buildCodexAppServerExitError(''))).toBeUndefined() + }) +}) diff --git a/src/main/codex/codex-app-server-exit-error.ts b/src/main/codex/codex-app-server-exit-error.ts index 85d8bc7dd6e..8812e6698a0 100644 --- a/src/main/codex/codex-app-server-exit-error.ts +++ b/src/main/codex/codex-app-server-exit-error.ts @@ -2,6 +2,7 @@ // stderr tail is the only evidence: a CLI without the subcommand is a durable // capability fact, and anything else is this run's crash. +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' import { CodexAppServerUnsupportedError } from './codex-app-server-session' @@ -9,11 +10,17 @@ const EXIT_DETAIL_MAX_CHARS = 400 export function buildCodexAppServerExitError(stderrTail: string, cause?: Error): Error { const tail = stderrTail.trim().slice(0, EXIT_DETAIL_MAX_CHARS) + // The tail is Codex's own stderr: a log, kept behind Details. + const diagnostic = providerDiagnostic(tail, 'log') if (stderrIndicatesMissingAppServer(stderrTail)) { - return new CodexAppServerUnsupportedError( - `codex CLI does not support the app-server subcommand: ${tail}` + return withProviderDiagnostic( + new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${tail}` + ), + diagnostic ) } const detail = cause ? `: ${cause.message}` : tail ? `: ${tail}` : '' - return new Error(`codex app-server connection ended${detail}`) + const error = new Error(`codex app-server connection ended${detail}`) + return cause ? error : withProviderDiagnostic(error, diagnostic) } diff --git a/src/main/codex/codex-app-server-record-dispatch.ts b/src/main/codex/codex-app-server-record-dispatch.ts index e76f2509399..b1d3605b38f 100644 --- a/src/main/codex/codex-app-server-record-dispatch.ts +++ b/src/main/codex/codex-app-server-record-dispatch.ts @@ -88,7 +88,8 @@ export function createCodexAppServerRecordDispatcher(input: { : new CodexAppServerRequestError( waiter.method, typeof error.code === 'number' ? error.code : null, - `codex app-server ${waiter.method} failed: ${detail}` + `codex app-server ${waiter.method} failed: ${detail}`, + typeof error.message === 'string' ? error.message : undefined ) ) return diff --git a/src/main/codex/codex-app-server-request-error.ts b/src/main/codex/codex-app-server-request-error.ts index 0dbefaca965..0e7cc719017 100644 --- a/src/main/codex/codex-app-server-request-error.ts +++ b/src/main/codex/codex-app-server-request-error.ts @@ -1,12 +1,23 @@ +import { providerDiagnostic, type ProviderDiagnostic } from '../../shared/agent-session-failure' + /** Codex answered the call and refused it, rather than timing out or exiting. */ export class CodexAppServerRequestError extends Error { + /** Codex's own `error.message`, kept apart from the Orca text around it. */ + readonly providerDiagnostic?: ProviderDiagnostic + constructor( readonly method: string, readonly code: number | null, - message: string + message: string, + providerMessage?: string ) { super(message) this.name = 'CodexAppServerRequestError' + const diagnostic = + providerMessage === undefined ? undefined : providerDiagnostic(providerMessage, 'person') + if (diagnostic) { + this.providerDiagnostic = diagnostic + } } } diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts new file mode 100644 index 00000000000..437f32f0a8f --- /dev/null +++ b/src/main/codex/codex-structured-compaction-refusal.test.ts @@ -0,0 +1,28 @@ +// A compaction Codex refuses up front keeps Codex's own words for the chat's failure row. + +import { describe, expect, it } from 'vitest' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { acquired, fakeCodex } from './codex-structured-session-adapter-fixture' + +describe('Codex compaction refused at the request', () => { + it("carries Codex's message as the detail, apart from Orca's wrapper", async () => { + const codex = fakeCodex({ + 'thread/compact/start': () => { + throw new CodexAppServerRequestError( + 'thread/compact/start', + -32600, + 'codex app-server thread/compact/start failed: thread has nothing to compact', + 'thread has nothing to compact' + ) + } + }) + const adapter = await acquired(codex) + + await expect( + adapter.compact({ turnId: 'compact-1', sessionId: 'session-1', fence: 7 }) + ).resolves.toEqual({ + outcome: 'failed', + detail: { text: 'thread has nothing to compact', audience: 'person' } + }) + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts index 5fc811b9382..b8ea81d592c 100644 --- a/src/main/codex/codex-structured-dispatch-admission.test.ts +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -140,7 +140,12 @@ describe('codex dispatch admission', () => { const { CodexAppServerRequestError } = await import('./codex-app-server-connection') const codex = fakeCodexAppServer({ 'turn/start': () => { - throw new CodexAppServerRequestError('turn/start', -32602, 'thread not found') + throw new CodexAppServerRequestError( + 'turn/start', + -32602, + 'codex app-server turn/start failed: thread not found', + 'thread not found' + ) } }) const settlements: LateSettlement[] = [] @@ -148,9 +153,14 @@ describe('codex dispatch admission', () => { const connection = codex.connections[0]! startTurn(connection, 'turn-1') + // Codex's own words reach the sentence and the fact; Orca's prefix reaches neither. expect(await send(adapter, 'client-1')).toEqual({ state: 'rejected', - reason: 'thread not found' + reason: 'The provider did not accept this message: thread not found.', + rejection: { + kind: 'providerRejected', + detail: { text: 'thread not found', audience: 'person' } + } }) // A refused write is disarmed, so a later echo of that id settles nothing. @@ -347,7 +357,8 @@ describe('codex dispatch admission', () => { } expect(await send(adapter, 'client-overflow')).toEqual({ state: 'rejected', - reason: 'codex structured dispatch queue is full' + reason: 'codex structured dispatch queue is full', + rejection: { kind: 'queueFull' } }) echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u0', clientId: 'client-0' }) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 4ecfcf2da01..c1a020b2c1f 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -1,8 +1,17 @@ +import type { ProviderDiagnostic } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -/** Sends awaiting their echo. A send whose echo never arrives is - * retired by the journal's pending-submission recovery on exit, not from here. */ +/** Sends awaiting their echo. One bound to a turn that ended without taking it settles from that + * end; any other whose echo never arrives is retired by the journal's recovery on exit. */ export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 +/** Turn ends kept for an answer read after the turn it names had already ended. */ +export const MAX_CODEX_RECORDED_TURN_ENDS = 64 + +/** How a primary-thread turn ended, as Codex reported it. */ +export type CodexTurnEnd = + | { status: 'completed' } + | { status: 'interrupted' } + | { status: 'failed'; detail?: ProviderDiagnostic } export type CodexDispatchRequestOrigin = { requestedAt: number @@ -24,6 +33,16 @@ export type CodexDispatchEchoes = { settle: (clientMessageId: string) => boolean /** Drops an armed send whose write never reached the provider. */ disarm: (clientMessageId: string) => void + /** + * Binds a send to the turn Codex answered it into. Returns that turn's end when the answer is + * read after it; a send that end settles is no longer armed. + */ + bindTurn: (clientMessageId: string, threadId: string, turnId: string) => CodexTurnEnd | 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. + */ + endTurn: (threadId: string, turnId: string, end: CodexTurnEnd) => string[] /** Submission origin for this exact send, retained until its echo settles it. */ requestOrigin: (clientMessageId: string) => CodexDispatchRequestOrigin | null /** Highest causal sequence assigned to a dispatch in this session. */ @@ -33,8 +52,11 @@ export type CodexDispatchEchoes = { } export function createCodexDispatchEchoes(): CodexDispatchEchoes { - const armed = new Map() + const armed = new Map() + const endedTurns = new Map() let nextSequence = 0 + const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) + const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' return { arm(clientMessageId, requestedAt) { const existing = armed.get(clientMessageId) @@ -52,6 +74,40 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { }, settle: (clientMessageId) => armed.delete(clientMessageId), disarm: (clientMessageId) => void armed.delete(clientMessageId), + bindTurn: (clientMessageId, threadId, turnId) => { + const entry = armed.get(clientMessageId) + if (!entry) { + return null + } + const turn = turnKey(threadId, turnId) + entry.turn = turn + const end = endedTurns.get(turn) ?? null + if (end && settles(end)) { + armed.delete(clientMessageId) + } + return end + }, + endTurn: (threadId, turnId, end) => { + const turn = turnKey(threadId, turnId) + endedTurns.delete(turn) + endedTurns.set(turn, end) + for (const oldest of endedTurns.keys()) { + if (endedTurns.size <= MAX_CODEX_RECORDED_TURN_ENDS) { + break + } + endedTurns.delete(oldest) + } + if (!settles(end)) { + return [] + } + const settled = [...armed].flatMap(([clientMessageId, entry]) => + entry.turn === turn ? [clientMessageId] : [] + ) + for (const clientMessageId of settled) { + armed.delete(clientMessageId) + } + return settled + }, requestOrigin: (clientMessageId) => { const origin = armed.get(clientMessageId) return origin?.requestedAt === null || origin === undefined @@ -61,6 +117,7 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { latestSequence: () => nextSequence - 1, clear: () => { armed.clear() + endedTurns.clear() nextSequence = 0 }, get size() { diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts index 42efd57bab4..566922d7422 100644 --- a/src/main/codex/codex-structured-dispatch-test-support.ts +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -1,5 +1,4 @@ import type { - AgentJournalItemIdentity, AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' @@ -30,11 +29,9 @@ type FakeConnection = Omit & { calls: { method: string; params?: Record }[] } -export type LateSettlement = { - sessionId: string - clientMessageId: string - providerIdentity: AgentJournalItemIdentity -} +export type LateSettlement = Parameters< + NonNullable +>[0] /** A `codex app-server` whose turn traffic the test drives by hand. */ export function fakeCodexAppServer(routes: Record = {}): { 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 d4881de1897..2c958858816 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -37,6 +37,14 @@ type TurnBoundaryEvent = { dispatchSequenceAtReceipt?: number } +type TurnTerminal = { + state: 'completed' | 'interrupted' + completedAt: number + /** Null when Codex named no verdict, or when the host inferred this end itself. */ + outcome?: AgentJournalTurnOutcome | null + durationMs?: number | null +} + /** Opens and settles the durable lifecycle row for each primary-thread turn. */ export class CodexJournalTurnBoundaries { private readonly recentTurns = new CodexJournalRecentTurns() @@ -146,46 +154,12 @@ 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 requestOrigin = this.deps.activeTurns.requestOrigin(event.threadId, turnId) - const latestDispatchSequence = this.deps.activeTurns.latestDispatchSequence( - event.threadId, - turnId - ) - const admission = settleCodexJournalTurn({ - sink: this.deps.sink, - sessionId: event.sessionId, - threadId: event.threadId, - turnId, - turnLifecycle, - streams: this.deps.items.streams, - activeItems: this.deps.items.activeItems, - pendingPrompts: this.deps.pendingPrompts, - ...(this.deps.clearPromptTurn ? { clearPromptTurn: this.deps.clearPromptTurn } : {}), - linkageFor: this.deps.linkageFor + return this.end(event, turnId, { + state: codexTurnLifecycleState(status), + outcome: codexTurnOutcome(status), + completedAt: this.receiptTime(event), + durationMs: readCodexTurnDurationMs(event.params) }) - if (admission.accepted) { - if (turnLifecycle) { - this.recentTurns.remember( - event.threadId, - turnLifecycle, - requestOrigin, - latestDispatchSequence - ) - } - this.deps.items.ordinals.forgetTurn(event.threadId, turnId) - this.deps.activeTurns.forget(event.threadId, turnId) - this.deps.resetActivity(event.threadId) - } - return admission } /** @@ -204,18 +178,34 @@ export class CodexJournalTurnBoundaries { return suppressionAdmission } const turnId = readCodexTurnId(event.params) ?? this.deps.activeTurns.current(event.threadId) - // An error naming an already-settled turn is not a second end: its terminal - // row holds the start and duration this one could not reconstruct. + // An error ends only a turn this host saw open; its `turn/completed` settles any other. if (!turnId || !this.deps.activeTurns.isActive(event.threadId, turnId)) { return CODEX_JOURNAL_ADMITTED } + return this.end(event, turnId, { + state: 'completed', + outcome: 'failure', + completedAt: this.receiptTime(event) + }) + } + + /** + * A turn's first terminal settlement is final. Codex follows a turn-ending + * `error` with a failed `turn/completed` for the same turn, and by then the + * start and attributed send this row carries are forgotten, so a second end + * could only overwrite the record with less. + */ + private end( + event: TurnBoundaryEvent, + turnId: string, + terminal: TurnTerminal + ): CodexJournalTranslationAdmission { + if (this.recentTurns.has(event.threadId, turnId)) { + return CODEX_JOURNAL_ADMITTED + } const turnLifecycle = event.threadId === this.deps.primaryThreadId() - ? this.settled(event.threadId, turnId, { - state: 'completed', - outcome: 'failure', - completedAt: this.receiptTime(event) - }) + ? this.settled(event.threadId, turnId, terminal) : null const requestOrigin = this.deps.activeTurns.requestOrigin(event.threadId, turnId) const latestDispatchSequence = this.deps.activeTurns.latestDispatchSequence( @@ -252,17 +242,7 @@ export class CodexJournalTurnBoundaries { /** Terminal lifecycle for a remembered turn; `startedAt` is absent when the start was never seen. * The verdict travels as one record so a caller cannot supply the state and drop the outcome. */ - settled( - threadId: string, - turnId: string, - terminal: { - state: 'completed' | 'interrupted' - completedAt: number - /** Null when Codex named no verdict, or when the host inferred this end itself. */ - outcome?: AgentJournalTurnOutcome | null - durationMs?: number | null - } - ): AgentJournalTurnLifecycle { + settled(threadId: string, turnId: string, terminal: TurnTerminal): AgentJournalTurnLifecycle { const startedAt = this.deps.activeTurns.startedAt(threadId, turnId) // Carried forward from the exact echoed send that was attributed to this turn. const requestOrigin = this.deps.activeTurns.requestOrigin(threadId, turnId) diff --git a/src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts b/src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts new file mode 100644 index 00000000000..a5c02bd3bfe --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation-turn-settles-once.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +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 { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +const SESSION_ID = 'session-1' +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' +const NEXT_TURN_ID = 'turn-2' +const CLIENT_MESSAGE_ID = 'client-1' + +type Row = { key: string; body: AgentJournalItemBody } + +function recorder() { + const rows: Row[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity: AgentJournalItemIdentity, body) => + rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: () => {}, + publish: () => {} + } + return { sink, rows } +} + +function notification(method: string, params: unknown, observedAt: number) { + return { + type: 'notification', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method, + params, + observedAt + } satisfies CodexStructuredSessionEvent +} + +function translator(tap: ReturnType) { + return createCodexJournalTranslator({ + sink: tap.sink, + sessionId: SESSION_ID, + primaryThreadId: () => THREAD_ID, + dispatchRequestOrigin: () => ({ requestedAt: 900, sequence: 0 }) + }) +} + +/** Every terminal write for a turn, in order: what a reader could observe. */ +function terminalWrites(rows: readonly Row[], turnId: string) { + return rows + .map((row) => row.body) + .filter((body) => body.kind === 'turn' && body.turnId === turnId && body.state !== 'running') +} + +/** The body the journal reducer keeps for a turn's lifecycle row. */ +function settledRecord(rows: readonly Row[], turnId: string) { + return rows + .map((row) => row.body) + .findLast((body) => body.kind === 'turn' && body.turnId === turnId) +} + +/** Codex's frames for a turn that fails: the error, then the failed completion. */ +function runFailedTurn(handle: (event: CodexStructuredSessionEvent) => unknown) { + handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + handle( + notification( + 'item/started', + { + turnId: TURN_ID, + turn: { id: TURN_ID }, + item: { type: 'userMessage', id: 'user-1', clientId: CLIENT_MESSAGE_ID } + }, + 1_100 + ) + ) + handle( + notification( + 'item/completed', + { + turnId: TURN_ID, + item: { type: 'agentMessage', id: 'agent-1', text: 'Checking the build' } + }, + 1_500 + ) + ) + handle( + notification( + 'error', + { + threadId: THREAD_ID, + turnId: TURN_ID, + willRetry: false, + error: { message: 'stream disconnected before completion' } + }, + 2_000 + ) + ) + handle( + notification( + 'turn/completed', + { turn: { id: TURN_ID, status: 'failed', durationMs: 1_100 } }, + 2_100 + ) + ) +} + +describe('a Codex turn settles once', () => { + it('keeps the failure the error settled when the failed completion follows it', () => { + const tap = recorder() + const codex = translator(tap) + + runFailedTurn((event) => codex.handle(event)) + + expect(terminalWrites(tap.rows, TURN_ID)).toHaveLength(1) + expect(settledRecord(tap.rows, TURN_ID)).toEqual({ + kind: 'turn', + turnId: TURN_ID, + state: 'completed', + outcome: 'failure', + userItemId: agentJournalSubmissionKey(CLIENT_MESSAGE_ID), + startedAt: 1_000, + requestedAt: 900, + completedAt: 2_000 + }) + expect( + tap.rows.filter((row) => row.body.kind === 'status' && row.body.tone === 'error') + ).toHaveLength(1) + }) + + it('ignores a duplicate completion for a turn it already settled', () => { + const tap = recorder() + const codex = translator(tap) + + codex.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + codex.handle( + notification( + 'turn/completed', + { turn: { id: TURN_ID, status: 'completed', durationMs: 900 } }, + 2_000 + ) + ) + codex.handle(notification('turn/completed', { turn: { id: TURN_ID, status: 'failed' } }, 3_000)) + + expect(terminalWrites(tap.rows, TURN_ID)).toHaveLength(1) + expect(settledRecord(tap.rows, TURN_ID)).toMatchObject({ + state: 'completed', + outcome: 'success', + startedAt: 1_000, + completedAt: 2_000, + durationMs: 900 + }) + }) + + it('settles an ordinary turn exactly as before', () => { + const tap = recorder() + const codex = translator(tap) + + codex.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + codex.handle( + notification( + 'turn/completed', + { turn: { id: TURN_ID, status: 'completed', durationMs: 3_250 } }, + 4_500 + ) + ) + + expect(terminalWrites(tap.rows, TURN_ID)).toEqual([ + { + kind: 'turn', + turnId: TURN_ID, + state: 'completed', + outcome: 'success', + userItemId: `codex:${THREAD_ID}:${TURN_ID}:0`, + startedAt: 1_000, + completedAt: 4_500, + durationMs: 3_250 + } + ]) + }) + + it('settles the next turn on its own after a failed one', () => { + const tap = recorder() + const codex = translator(tap) + + runFailedTurn((event) => codex.handle(event)) + const failed = settledRecord(tap.rows, TURN_ID) + codex.handle(notification('turn/started', { turn: { id: NEXT_TURN_ID } }, 3_000)) + codex.handle( + notification( + 'turn/completed', + { turn: { id: NEXT_TURN_ID, status: 'completed', durationMs: 1_000 } }, + 4_000 + ) + ) + + expect(settledRecord(tap.rows, TURN_ID)).toEqual(failed) + expect(terminalWrites(tap.rows, NEXT_TURN_ID)).toHaveLength(1) + expect(settledRecord(tap.rows, NEXT_TURN_ID)).toMatchObject({ + state: 'completed', + outcome: 'success', + startedAt: 3_000, + completedAt: 4_000 + }) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation-turn-state.ts b/src/main/codex/codex-structured-journal-translation-turn-state.ts index ec1cff041db..832f57f3096 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-state.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-state.ts @@ -167,7 +167,8 @@ type RecentTurn = { bytes: number } -/** Bounded terminal lifecycle window for exact echoes that arrive after completion. */ +/** Bounded terminal lifecycle window: exact echoes that arrive after completion + * revise it, and a later end for a turn in it is not a second settlement. */ export class CodexJournalRecentTurns { private readonly turns = new Map() private retainedBytes = 0 @@ -231,6 +232,10 @@ export class CodexJournalRecentTurns { } } + has(threadId: string, turnId: string): boolean { + return this.turns.has(this.turnKey(threadId, turnId)) + } + requestOriginRevision( threadId: string, turnId: string, 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 new file mode 100644 index 00000000000..6459b59755c --- /dev/null +++ b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts @@ -0,0 +1,111 @@ +// A failed Codex turn through the real path its record takes: +// translator → deferred sink queue → on-disk journal → the shared turn-timing reader. + +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 { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { + completedStructuredAgentTurnSeconds, + selectStructuredAgentRunningTurnTiming +} from '../../shared/structured-agent-session-turn-timing' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' + +const SESSION = 'session-codex-failed-turn' +const THREAD = 'thread-abc' +const TURN = 'turn-1' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } +}) + +async function session() { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-failed-turn-')) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: root, + now: () => 1_000 + }) + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ journal, fence: 1, publish: () => {} }) + cleanups.push(async () => { + deferred.close() + await journal.close() + await rm(root, { recursive: true, force: true }) + }) + const translator = createCodexJournalTranslator({ + sink: deferred.sink, + sessionId: SESSION, + primaryThreadId: () => THREAD, + schedule: (run) => { + run() + return () => {} + } + }) + const on = (method: string, params: Record, observedAt: number) => + translator.handle({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method, + params: { threadId: THREAD, ...params }, + observedAt + }) + return { + on, + drained: () => deferred.drained(), + items: async () => { + await deferred.drained() + return journal.snapshot().items + } + } +} + +describe('a failed Codex turn in the journal', () => { + it('keeps the failure and its duration when the failed completion lands while the error is still queued', async () => { + const { on, drained, items } = await session() + on('turn/started', { turn: { id: TURN } }, 1_000) + on( + 'item/completed', + { turnId: TURN, item: { type: 'agentMessage', id: 'agent-1', text: 'Checking the build' } }, + 1_500 + ) + await drained() + + // Codex writes both frames back to back; the error's status row is still being + // written when the failed completion arrives, so the error's settlement is queued. + on( + 'error', + { turnId: TURN, willRetry: false, error: { message: 'stream disconnected' } }, + 3_000 + ) + on('turn/completed', { turn: { id: TURN, status: 'failed', durationMs: 1_900 } }, 3_100) + + const rows = await items() + const turn = rows + .map((item) => readAgentJournalTurn(item.body)) + .findLast((record) => record?.turnId === TURN) + expect(turn).toMatchObject({ + state: 'completed', + outcome: 'failure', + startedAt: 1_000, + completedAt: 3_000 + }) + // The duration "Worked for" shows under the turn's message. + expect( + completedStructuredAgentTurnSeconds(selectStructuredAgentRunningTurnTiming(rows, TURN)) + ).toBe(2) + }) +}) diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts new file mode 100644 index 00000000000..ff9a210e17a --- /dev/null +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -0,0 +1,257 @@ +// A Codex ask with several questions, driven from the real host journal through +// the client's session reducer to the rows the transcript list draws. +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 type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} 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 { 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' +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' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + projectStructuredQuestionMessages, + structuredQuestionTranscript +} from '../../renderer/src/components/native-chat/structured-agent-question-projection' + +const CALLER = { callerKey: 'client-1' } + +type Asked = readonly { id: string; question: string }[] + +// Codex's question ids are the model's own words, so their text order is not +// the order it asked in. These two asks spell the two orders live QA saw. +const ASKED: Asked = [ + { id: 'scope', question: 'Which files are in scope?' }, + { id: 'priority', question: 'What matters most?' }, + { id: 'deadline', question: 'When is it due?' } +] +const ASKED_OUT_OF_ORDER: Asked = [ + { id: 'format', question: 'Which format?' }, + { id: 'audience', question: 'Who reads it?' }, + { id: 'length', question: 'How long?' } +] + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink | null +let client: StructuredAgentSessionState +let clock: number + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-question-order-')) + resetHostTestOperationIds() + sink = null + clock = HOST_TEST_NOW + const adapter: StructuredAgentSessionAdapter = { + acquire: vi.fn(async ({ fence, events }) => { + sink = events ?? null + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: HOST_TEST_NOW + } + } + }), + releaseAcquisition: vi.fn(async () => true), + dispatch: vi.fn(), + 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' }) + host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + // Every write lands on its own millisecond, as it does live. + now: () => (clock += 1) + }) + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('no history page') + } + client = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'history-page', + page: page.page + }) + host.subscribe({ + id: 'client', + sessionId: SESSION, + cursor: page.page.liveCursor ?? page.page.window.nextCursor, + emit: (event) => { + client = reduceStructuredAgentSession(client, { type: 'event', event }) + } + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function codexPrompts(): CodexJournalPrompts { + if (!sink) { + throw new Error('session was never acquired') + } + return new CodexJournalPrompts( + { sink, linkageFor: () => ({}) }, + () => null, + () => 'turn-1' + ) +} + +async function ask(prompts: CodexJournalPrompts, asked: Asked = ASKED): Promise { + prompts.handle({ + threadId: THREAD, + method: CODEX_USER_INPUT_METHOD, + codexItemId: 'codex-item-1', + promptKey: '7', + params: { + threadId: THREAD, + turnId: 'turn-1', + questions: asked.map((question) => ({ + ...question, + options: [ + { label: 'Yes', description: '' }, + { label: 'No', description: '' } + ] + })) + } + }) + await host.flushStreamedEvents(SESSION) +} + +function questionItem(question: string): AgentJournalRenderItem { + const item = client.items.find( + (candidate) => candidate.body.kind === 'question' && candidate.body.question === question + ) + if (!item || item.body.kind !== 'question') { + throw new Error(`no journal item for ${question}`) + } + return item +} + +async function answer(question: string): Promise { + const item = questionItem(question) + if (item.body.kind !== 'question') { + return + } + const fields = { + itemId: item.itemId, + expectedRevision: item.revision, + optionId: item.body.options[0]!.id + } + const result = await host.respondToPrompt(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.respondTo:question', + sessionId: SESSION, + fields + }) + }, + kind: 'question', + ...fields + }) + expect(result.ok).toBe(true) + await host.flushStreamedEvents(SESSION) +} + +/** The prompt rows the transcript list draws, top to bottom, as the questions each one shows. */ +function drawnPromptRows(): string[][] { + const { receipts } = structuredQuestionTranscript(client.items) + // The desktop list's projection: its comparator adds only a rank for rows the host never writes. + const rows = projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages( + client.items, + [], + client.submissions, + projectStructuredQuestionMessages + ) + ) + return rows.flatMap((row) => { + const prompt = receipts.get(row.id) + if (!prompt || prompt.kind !== 'question') { + return [] + } + const questions = prompt.questions?.length ? prompt.questions : [prompt] + return [questions.map(({ question }) => `${question} (${prompt.resolution.state})`)] + }) +} + +describe('a Codex ask with several questions', () => { + it('keeps the pending rest of the ask below the question already answered', async () => { + await ask(codexPrompts()) + await answer(ASKED[0]!.question) + + expect(drawnPromptRows()).toEqual([ + ['Which files are in scope? (resolved)'], + ['What matters most? (pending)', 'When is it due? (pending)'] + ]) + }) + + it('draws every answered question in the order Codex asked it', async () => { + await ask(codexPrompts()) + for (const { question } of ASKED) { + await answer(question) + } + + expect(drawnPromptRows()).toEqual([ + ['Which files are in scope? (resolved)'], + ['What matters most? (resolved)'], + ['When is it due? (resolved)'] + ]) + // Mobile draws the shared projection in journal order, one row per question. + expect( + projectStructuredAgentSessionMessages(client.items, [], client.submissions).map( + ({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null) + ) + ).toEqual(ASKED.map(({ question }) => question)) + }) + + it('draws a cancelled ask in the order Codex asked it', async () => { + const prompts = codexPrompts() + await ask(prompts, ASKED_OUT_OF_ORDER) + prompts.cancel(questionItem(ASKED_OUT_OF_ORDER[0]!.question).itemId) + await host.flushStreamedEvents(SESSION) + + expect(drawnPromptRows()).toEqual([ + ['Which format? (cancelled)'], + ['Who reads it? (cancelled)'], + ['How long? (cancelled)'] + ]) + }) +}) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index ae92bd48f54..b7262ea6fee 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -203,7 +203,7 @@ export async function acquireCodexStructuredSession(input: { primaryThreadId = opened.threadId const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {}) if (restoreAdmission && !restoreAdmission.accepted) { - throw new AgentSessionAcquisitionRefusal( + throw AgentSessionAcquisitionRefusal.historyTooLarge( 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' ) } 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 6389cbe70d5..bcfd2f733f0 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -181,6 +181,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { type: 'ended', sessionId: 'session-1', reason: 'codex app-server connection ended', + failure: { kind: 'providerExited' }, cause: 'unexpected-exit', fence: 7, acquisitionGeneration: 'generation-1', diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 169772d4992..43b76536c64 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -466,7 +466,12 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { body: USER_MESSAGE, fence: 7 }) - ).toEqual({ state: 'rejected', reason: 'turn already running' }) + ).toEqual({ + // Built without Codex's own words, so nothing is quoted and no detail is invented. + state: 'rejected', + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected' } + }) }) it('rethrows a dead child so the wire settles the submission unknown', async () => { diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index b9affd8bd00..0b1b8145fcc 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -34,6 +34,7 @@ import { translateCodexNotification } from './codex-structured-provider-events' import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' +import { 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' @@ -154,6 +155,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } 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 }) + ) // After the admission check, so a refused frame is observed by the strip // only on the retry that also reaches the journal. if (session.backgroundTasks.observe(event)) { @@ -257,12 +262,16 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap { threadId: session.threadId }, { timeoutMs: this.deps.requestTimeoutMs } ) - .catch((error) => { - if (isCodexAppServerRequestError(error)) { - return { error: error.message } + .then( + () => undefined, + (error) => { + if (isCodexAppServerRequestError(error)) { + const detail = error.providerDiagnostic + return { outcome: 'failed' as const, ...(detail ? { detail } : {}) } + } + throw error } - throw error - }) + ) }, input.onLateResult, input.turnId @@ -277,7 +286,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.deps.requestTimeoutMs ) - supportsThreadGoal = (sessionId: string): boolean => this.sessions.has(sessionId) + // Provider-level: a goal change at rest starts the agent first. + supportsThreadGoal = (): boolean => true answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerCodexStructuredPrompt({ request, sessions: this.sessions }) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 0a1de244dca..56e1855c60f 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import type { CodexAppServerConnection } from './codex-app-server-connection-types' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' import { @@ -14,6 +15,9 @@ export function handleCodexSessionExit(input: { sessionId: string connection: CodexAppServerConnection | null error: Error + /** Set by Orca's own close. Absent only from the connection's onExit, which the connection + * withholds while Orca is closing the child. */ + closedByOrca?: true prompts?: CodexSession['prompts'] allowFailedSettlement?: boolean onEvent?: (event: CodexStructuredSessionEvent) => void @@ -29,6 +33,10 @@ export function handleCodexSessionExit(input: { type: 'ended', sessionId: input.sessionId, reason: input.error.message, + // Only the child's own exit blames Codex; a close Orca made, for any reason, is Orca's. + failure: input.closedByOrca + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, @@ -93,6 +101,7 @@ export async function closeCodexPublishedSession( sessionId, connection: session.connection, error: options?.unexpectedReason ?? new Error('codex session closed'), + closedByOrca: true, prompts: session.prompts, ...(options?.allowFailedSettlement ? { allowFailedSettlement: true } : {}), ...(onEvent ? { onEvent } : {}) diff --git a/src/main/codex/codex-structured-session-exit-blame.test.ts b/src/main/codex/codex-structured-session-exit-blame.test.ts new file mode 100644 index 00000000000..591f0e61ae8 --- /dev/null +++ b/src/main/codex/codex-structured-session-exit-blame.test.ts @@ -0,0 +1,68 @@ +// Only the Codex app-server child's own exit says Codex stopped; a close Orca made — a journal +// sink that could not take a frame, a forced close — is Orca's. + +import { describe, expect, it, vi } from 'vitest' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { adapterFor, fakeCodex, identityFor } from './codex-structured-session-adapter-fixture' +import type { CodexStructuredSessionEvent } from './codex-structured-session-state' + +function sink(overrides: Partial = {}) { + return { appendItem: vi.fn(), appendTombstone: vi.fn(), publish: vi.fn(), ...overrides } +} + +async function startedCodex(events: StructuredAgentSessionEventSink) { + const codex = fakeCodex() + const emitted: CodexStructuredSessionEvent[] = [] + const adapter = adapterFor(codex, {}, emitted) + await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 's', events }) + const ended = async () => { + await vi.waitFor(() => expect(emitted.some((event) => event.type === 'ended')).toBe(true)) + return emitted.filter((event) => event.type === 'ended') + } + return { adapter, connection: codex.connections[0], ended } +} + +describe('what a started Codex session says ended it', () => { + it('says Codex stopped when the child exits on its own', async () => { + const { connection, ended } = await startedCodex(sink()) + + connection.handlers.onExit?.(new Error('codex app-server connection ended: killed')) + + const [event] = await ended() + expect(event).toMatchObject({ cause: 'unexpected-exit', failure: { kind: 'providerExited' } }) + const failure = event && 'failure' in event ? event.failure : undefined + expect( + failure && agentSessionFailureWords(failure, { surface: 'row', agentName: 'Codex' }).text + ).toBe( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) + }) + + it('blames Orca when a journal sink failure makes Orca close the child', async () => { + const { connection, ended } = await startedCodex( + sink({ tryAppendItem: () => ({ accepted: false, reason: 'failed' }) }) + ) + + connection.handlers.onUnhandledFrame?.('frame:invalid-json', '{') + + expect(await ended()).toMatchObject([ + { + reason: 'Codex provider frame frame:invalid-json could not be durably recorded (failed)', + cause: 'unexpected-exit', + failure: { kind: 'hostFault' } + } + ]) + expect(connection.closeCount).toBe(1) + }) + + it('blames Orca for a forced close', async () => { + const { adapter, ended } = await startedCodex(sink()) + + await expect(adapter.forceCloseSession('session-1')).resolves.toBe(true) + + expect(await ended()).toMatchObject([ + { cause: 'unexpected-exit', failure: { kind: 'hostFault' } } + ]) + }) +}) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index f4828b7df68..4fff94431e5 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -3,6 +3,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { randomUUID } from 'node:crypto' +import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import type { CodexAppServerConnection, @@ -77,12 +78,14 @@ export type CodexStructuredSessionAdapterDeps = { sessionId: string, state: AgentSessionBackgroundTaskState | null ) => void - /** Identity for a send admitted earlier, once Codex echoes the user message. */ - onDispatchSettledLate?: (input: { - sessionId: string - clientMessageId: string - providerIdentity: AgentJournalItemIdentity - }) => void + /** A send admitted earlier: its identity once Codex echoes it, or its rejection when the turn + * Codex answered it into ended without taking it. */ + onDispatchSettledLate?: ( + input: { sessionId: string; clientMessageId: string } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + ) + ) => void /** Codex reported its thread not running with no turn open: a send whose * dispatch was never answered is owed nothing after this. */ onPrimaryThreadStoppedRunning?: (input: { sessionId: string }) => void diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index 7422d7e44ff..8db0bbeec61 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -47,6 +47,11 @@ export function readCodexTurnStatus(payload: unknown): string | null { return nonEmptyString(record(root.turn)?.status) ?? nonEmptyString(root.status) } +/** A failed `turn/completed` carries Codex's reason as `turn.error.message`. */ +export function readCodexTurnErrorMessage(payload: unknown): string | null { + return nonEmptyString(record(record(record(payload)?.turn)?.error)?.message) +} + /** Codex's own turn duration, already in milliseconds; absent or malformed reads as null. */ export function readCodexTurnDurationMs(payload: unknown): number | null { const root = record(payload) diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts new file mode 100644 index 00000000000..60f5a3c5f38 --- /dev/null +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { + acquiredCodexAdapter, + CODEX_TEST_THREAD_ID, + CODEX_TEST_USER_MESSAGE, + fakeCodexAppServer, + type LateSettlement +} from './codex-structured-dispatch-test-support' +import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' + +async function turnEndRig() { + const codex = fakeCodexAppServer() + const turns = codexTurnLifecycleFake(CODEX_TEST_THREAD_ID, () => { + const handlers = codex.connections.at(-1)?.handlers + return (method, params) => handlers?.onNotification?.(method, params) + }) + Object.assign(codex.routes, turns.routes) + const settlements: LateSettlement[] = [] + const bodies: AgentJournalItemBody[] = [] + const adapter = await acquiredCodexAdapter({ + codex, + settlements, + sink: { + appendItem: (_identity, body) => bodies.push(body), + appendTombstone: () => {}, + publish: () => {} + } + }) + const send = (clientMessageId: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: CODEX_TEST_USER_MESSAGE, + fence: 7 + }) + 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 } +} + +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() + + rig.turns.end('interrupted') + + expect(rig.settlements).toEqual([ + expect.objectContaining({ + sessionId: 'session-1', + clientMessageId: 'client-1', + state: 'rejected' + }) + ]) + expect(rig.categoryOf(rig.settlements[0])).toBe('withdrawn') + }) + + 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() + + rig.codex.connections[0]!.handlers.onNotification?.('error', { + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + willRetry: false, + error: { message: 'usage limit reached' } + }) + rig.turns.end('failed', 'usage limit reached') + + expect(rig.settlements).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-1', + state: 'rejected', + rejection: { + kind: 'providerRejected', + detail: { text: 'usage limit reached', audience: 'person' } + } + }) + ]) + }) + + 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() + rig.turns.echo('client-1') + await rig.send('client-2') + + // A failed turn keeps its steered input: Codex records it after the error, before the end. + rig.codex.connections[0]!.handlers.onNotification?.('error', { + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + willRetry: false, + error: { message: 'usage limit reached' } + }) + rig.turns.echo('client-2') + rig.turns.end('failed', 'usage limit reached') + + expect(rig.settlements).toEqual([ + expect.objectContaining({ clientMessageId: 'client-1', providerIdentity: expect.anything() }), + expect.objectContaining({ + clientMessageId: 'client-2', + providerIdentity: expect.objectContaining({ turnId: 'turn-1' }) + }) + ]) + }) + + 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() + + rig.turns.end('completed') + expect(rig.settlements).toEqual([]) + + // Codex echoes before a completed end; this late one only proves the send is still armed. + rig.turns.echo('client-1') + expect(rig.settlements).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-1', + providerIdentity: expect.objectContaining({ turnId: 'turn-1' }) + }) + ]) + }) + + 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() + rig.turns.echo('client-1') + + rig.turns.end('interrupted') + + expect(rig.settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 0 + } + } + ]) + }) + + it('ignores an echo that arrives after the withdrawal', async () => { + const rig = await turnEndRig() + await rig.send('client-1') + rig.turns.start() + rig.turns.end('interrupted') + + rig.turns.echo('client-1') + + expect(rig.settledIds()).toEqual(['client-1']) + expect(rig.categoryOf(rig.settlements[0])).toBe('withdrawn') + }) + + 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.send('client-2') + expect(rig.turns.turnId).toBe('turn-1') + + rig.turns.end('interrupted') + + expect(rig.settledIds().sort()).toEqual(['client-1', 'client-2']) + }) + + it('settles a send whose answer arrived after turn/started when the turn is interrupted', async () => { + const rig = await turnEndRig() + 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 expect(sending).resolves.toEqual({ state: 'admitted' }) + + rig.turns.end('interrupted') + + expect(rig.settledIds()).toEqual(['client-1']) + }) + + it('settles a send by the recorded end of a turn that finished before its answer arrived', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + rig.turns.end('interrupted') + const rowsAtEnd = rig.bodies.filter((body) => body.kind === 'turn').length + release() + + const outcome = await sending + expect(outcome.state === 'rejected' && classifyDispatchRejection(outcome).category).toBe( + 'withdrawn' + ) + expect(rig.settlements).toEqual([]) + // The answer opens nothing: the turn keeps its terminal row. + expect(rig.bodies.filter((body) => body.kind === 'turn')).toHaveLength(rowsAtEnd) + expect(rig.bodies.findLast((body) => body.kind === 'turn')).toMatchObject({ + state: 'interrupted' + }) + }) + + it('rejects a send in Codex words by the recorded end of a failed turn that finished before its answer', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + rig.turns.end('failed', 'usage limit reached') + release() + + await expect(sending).resolves.toMatchObject({ + state: 'rejected', + rejection: { + kind: 'providerRejected', + detail: { text: 'usage limit reached', audience: 'person' } + } + }) + // Settled once, by the answer: a late echo is no longer owed anything. + rig.turns.echo('client-1') + expect(rig.settlements).toEqual([]) + }) + + it('leaves a send pending, still armed, when its answer is read after its turn completed', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + rig.turns.end('completed') + release() + + await expect(sending).resolves.toEqual({ state: 'admitted' }) + expect(rig.settlements).toEqual([]) + rig.turns.echo('client-1') + expect(rig.settledIds()).toEqual(['client-1']) + expect(rig.settlements[0]).toHaveProperty('providerIdentity') + }) + + it('leaves a send whose answer timed out to its echo', async () => { + const rig = await turnEndRig() + rig.codex.routes['turn/start'] = () => { + throw new Error('codex app-server turn/start exceeded 30000ms') + } + await expect(rig.send('client-1')).rejects.toThrow('exceeded') + + rig.codex.connections[0]!.handlers.onNotification?.('turn/started', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: 'turn-9' } + }) + rig.codex.connections[0]!.handlers.onNotification?.('item/completed', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: 'turn-9' }, + item: { type: 'userMessage', id: 'item-9', clientId: 'client-1', content: [] } + }) + + expect(rig.settlements).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-1', + providerIdentity: expect.objectContaining({ turnId: 'turn-9' }) + }) + ]) + }) + + it('ignores a turn end on a child thread', async () => { + const rig = await turnEndRig() + await rig.send('client-1') + rig.turns.start() + + rig.codex.connections[0]!.handlers.onNotification?.('turn/completed', { + threadId: 'thread-child', + turn: { id: 'turn-1', status: 'interrupted' } + }) + + expect(rig.settlements).toEqual([]) + }) +}) + +describe('a send bound to a turn', () => { + it('dies with the settlement its turn end makes', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + + expect(echoes.endTurn('thread-1', 'turn-1', { status: 'interrupted' })).toEqual(['client-1']) + expect(echoes.size).toBe(0) + expect(echoes.settle('client-1')).toBe(false) + }) + + it('dies with its child, which forgets recorded turn ends too', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.endTurn('thread-2', 'turn-2', { status: 'interrupted' }) + + echoes.clear() + + expect(echoes.size).toBe(0) + echoes.arm('client-2') + expect(echoes.bindTurn('client-2', 'thread-2', 'turn-2')).toBeNull() + }) + + it('is matched by thread as well as turn id', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1') + + expect(echoes.endTurn('thread-2', 'turn-1', { status: 'interrupted' })).toEqual([]) + expect(echoes.size).toBe(1) + }) +}) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts new file mode 100644 index 00000000000..5187aabf45a --- /dev/null +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -0,0 +1,96 @@ +// 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 +// 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. + +import { + agentSessionFailureFact, + providerDiagnostic, + type ProviderDiagnostic, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../shared/agent-session-failure-words' +import type { CodexTurnEnd } from './codex-structured-dispatch-echo' +import type { CodexSession } from './codex-structured-session-state' +import { + readCodexThreadId, + readCodexTurnErrorMessage, + readCodexTurnId, + readCodexTurnStatus +} from './codex-structured-thread-facts' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' + +/** A message Codex rejected, in the words that name Codex and its legacy markers. */ +export function codexDispatchRejection( + failure: SubmissionRejectionFact +): AgentJournalDispatchRejection { + return agentSessionFailureWords(failure, { + surface: 'rejection', + agentName: TUI_AGENT_DISPLAY_NAMES.codex, + provider: 'codex' + }) +} + +export type CodexTurnEndSettlement = { + clientMessageId: string + state: 'rejected' +} & AgentJournalDispatchRejection + +function errorDetail(params: unknown): ProviderDiagnostic | undefined { + const message = readCodexTurnErrorMessage(params) + return message ? providerDiagnostic(message, 'person') : undefined +} + +/** The end a primary-thread notification reports for its turn, or null for any other frame. */ +export function readCodexTurnEnd(method: string, params: unknown): CodexTurnEnd | null { + if (method !== 'turn/completed') { + return null + } + const status = readCodexTurnStatus(params) + if (status === 'interrupted') { + return { status: 'interrupted' } + } + if (status === 'failed') { + const detail = errorDetail(params) + return { status: 'failed', ...(detail ? { detail } : {}) } + } + return { status: 'completed' } +} + +/** How an ended turn settles a send it never echoed; null leaves the send to its echo. */ +export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRejection | null { + if (end.status === 'interrupted') { + return agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + } + if (end.status === 'failed') { + return codexDispatchRejection( + agentSessionFailureFact('providerRejected', end.detail ? { detail: end.detail } : {}) + ) + } + return null +} + +/** Settles the sends bound to the turn this admitted notification ended. */ +export function settleCodexSendsInEndedTurn( + session: Pick, + method: string, + params: unknown, + settle: (settlement: CodexTurnEndSettlement) => void +): void { + const turnId = readCodexTurnId(params) + const end = readCodexTurnEnd(method, params) + if (!turnId || !end || (readCodexThreadId(params) ?? session.threadId) !== session.threadId) { + return + } + const rejection = codexTurnEndRejection(end) + for (const clientMessageId of session.dispatchEchoes.endTurn(session.threadId, turnId, end)) { + if (rejection) { + settle({ clientMessageId, state: 'rejected', ...rejection }) + } + } +} diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 2d22ce42f3e..825a6c3c375 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -7,7 +8,11 @@ import { } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' -import { DISPATCH_REJECTED_CODEX_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' +import { readCodexTurnId } from './codex-structured-thread-facts' +import { + codexDispatchRejection, + codexTurnEndRejection +} from './codex-structured-turn-end-settlement' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' // Writing a Codex turn and learning which message landed where, which are not @@ -15,7 +20,8 @@ import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured // message issued while a turn is running is COALESCED into that turn: the same // turn id comes back, no second `turn/started` fires, and the user message is // echoed only when the running turn reaches it. So the response proves -// admission and nothing about identity, which the echo settles later. +// admission and nothing about identity, which the echo settles later. The turn +// it names is kept with the send, so that turn's end can settle it. /** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise * become an arbitrary client-controlled `turn/start` parameter. Permission posture is owned by @@ -86,7 +92,8 @@ function codexTurnOptions(host: CodexTurnHost): Record { /** * Hands one submission to Codex. False means the bounded correlation window - * refused it before the write; otherwise resolves when Codex has taken it. + * refused it before the write; otherwise resolves with the turn Codex answered + * it into, or null when the answer named none. */ export async function startCodexTurn( host: CodexTurnHost, @@ -96,13 +103,13 @@ export async function startCodexTurn( requestedAt?: number timeoutMs?: number } -): Promise { +): Promise<{ turnId: string | null } | false> { // Armed before the write: the echo and `turn/started` can both land while the // response is in flight, and the start must snapshot this send in its frontier. if (!host.dispatchEchoes.arm(input.clientMessageId, input.requestedAt)) { return false } - await host.connection.request( + const answer = await host.connection.request( 'turn/start', { threadId: host.threadId, @@ -112,7 +119,7 @@ export async function startCodexTurn( }, { timeoutMs: input.timeoutMs } ) - return true + return { turnId: readCodexTurnId(answer) } } /** @@ -126,19 +133,32 @@ export async function dispatchCodexTurn( input: { clientMessageId: string; body: AgentJournalMessageItem; requestedAt?: number }, timeoutMs: number | undefined ): Promise { + let answer: { turnId: string | null } | false try { - if (!(await startCodexTurn(session, { ...input, timeoutMs }))) { - return { state: 'rejected', reason: DISPATCH_REJECTED_CODEX_QUEUE_FULL } - } + answer = await startCodexTurn(session, { ...input, timeoutMs }) } catch (error) { if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { // Codex answered and declined, so no echo for this write can arrive. session.dispatchEchoes.disarm(input.clientMessageId) - return { state: 'rejected', reason: (error as Error).message } + // 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) }) + ) + } } // A timeout or transport failure can happen after the frame was written. // Keep the correlation armed so a later echo can prove delivery. throw error } - return { state: 'admitted' } + if (!answer) { + return { state: 'rejected', ...codexDispatchRejection(agentSessionFailureFact('queueFull')) } + } + // An answer read after the turn it names already ended is settled by that end. + const endedFirst = answer.turnId + ? session.dispatchEchoes.bindTurn(input.clientMessageId, session.threadId, answer.turnId) + : null + const rejection = endedFirst ? codexTurnEndRejection(endedFirst) : null + return rejection ? { state: 'rejected', ...rejection } : { state: 'admitted' } } diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts new file mode 100644 index 00000000000..4ed2507640b --- /dev/null +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -0,0 +1,112 @@ +// Codex 0.157's turn bookkeeping as `turn/start` and `turn/interrupt` see it, for +// tests. From app-server `turn_processor.rs`: `turn/start` picks the turn before it +// answers, a send while a turn is open is steered into it under the same id with no +// second `turn/started`, and `turn_interrupt_inner` refuses with -32600 until the +// turn has started. The answer can be held, so a test can deliver it after the +// turn's own frames, as the wire allows. + +import { CodexAppServerRequestError } from './codex-app-server-connection' + +type Notify = (method: string, params: unknown) => void + +export type CodexTurnLifecycleFake = { + routes: { + 'turn/start': () => unknown + 'turn/interrupt': (params: Record | undefined) => unknown + } + /** The next `turn/start` answer waits until the returned release runs. */ + holdNextAnswer: () => () => void + /** Codex emits `turn/started` for the turn it picked last. */ + start: () => void + /** Codex ends the picked or running turn on its own. */ + end: (status: 'completed' | 'interrupted' | 'failed', errorMessage?: string) => void + /** Codex echoes a user message it recorded in the current turn. */ + echo: (clientId: string) => void + readonly turnId: string | null +} + +function refusal(message: string): CodexAppServerRequestError { + return new CodexAppServerRequestError( + 'turn/interrupt', + -32600, + `codex app-server turn/interrupt failed: ${message}`, + message + ) +} + +export function codexTurnLifecycleFake( + threadId: string, + notify: () => Notify +): CodexTurnLifecycleFake { + let minted = 0 + let echoes = 0 + let picked: string | null = null + let active: string | null = null + let lastTurn: string | null = null + let held: Promise | null = null + const finish = (turnId: string, status: string, errorMessage?: string): void => { + picked = null + active = null + notify()('turn/completed', { + threadId, + turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } + }) + } + return { + routes: { + 'turn/start': () => { + const turnId = active ?? picked ?? `turn-${++minted}` + picked ??= active ? null : turnId + lastTurn = turnId + const answer = { turn: { id: turnId, status: 'inProgress' } } + const wait = held + held = null + return wait ? wait.then(() => answer) : answer + }, + 'turn/interrupt': (params) => { + const turnId = params?.turnId + if (!active) { + throw refusal('no active turn to interrupt') + } + if (active !== turnId) { + throw refusal(`expected active turn id ${String(turnId)} but found ${active}`) + } + // Codex answers the interrupt once the turn has aborted. + finish(active, 'interrupted') + return {} + } + }, + holdNextAnswer: () => { + let release!: () => void + held = new Promise((resolve) => { + release = resolve + }) + return () => release() + }, + start: () => { + if (!picked) { + throw new Error('no picked turn to start') + } + active = picked + notify()('turn/started', { threadId, turn: { id: active, status: 'inProgress' } }) + }, + end: (status, errorMessage) => { + const turnId = active ?? picked + if (!turnId) { + throw new Error('no turn to end') + } + finish(turnId, status, errorMessage) + }, + echo: (clientId) => { + const turnId = active ?? picked ?? lastTurn + notify()('item/completed', { + threadId, + turn: { id: turnId }, + item: { type: 'userMessage', id: `item-user-${++echoes}`, clientId, content: [] } + }) + }, + get turnId() { + return active ?? picked + } + } +} diff --git a/src/main/daemon/__fixtures__/freebuff-serialize-baseline.json b/src/main/daemon/__fixtures__/freebuff-serialize-baseline.json new file mode 100644 index 00000000000..1f35b2abaa0 --- /dev/null +++ b/src/main/daemon/__fixtures__/freebuff-serialize-baseline.json @@ -0,0 +1,30 @@ +{ + "freebuff-lifecycle": [ + "none/false/2/31:22f0e3d90d75ceaf6fd95853c0b772074e441596caadcea8d133b64bad5db0f6", + "none/true/2/31:22f0e3d90d75ceaf6fd95853c0b772074e441596caadcea8d133b64bad5db0f6", + "shrink/false/1/29:b5d123b659e7cdd5a3d8c6031529a032d60311b1073e46717d2cbed8d46b6ba4", + "shrink/false/2/32:c3408d293abaa4e25303a4d1acf1ea2f096a35ff685e548f98aec777355d10a5", + "shrink/false/2/34:6c1dd1d115eb1c2af110cfa30ce84649b206ff48c76bcbcdfa31877d1b918140", + "shrink/true/1/29:b5d123b659e7cdd5a3d8c6031529a032d60311b1073e46717d2cbed8d46b6ba4", + "shrink/true/2/32:c3408d293abaa4e25303a4d1acf1ea2f096a35ff685e548f98aec777355d10a5", + "shrink/true/2/34:6c1dd1d115eb1c2af110cfa30ce84649b206ff48c76bcbcdfa31877d1b918140", + "shrink-grow/false/2/33:407f38549bb5be0a2545ba448d21940a201f1cca92e8117e42f3b30907d7d486", + "shrink-grow/true/2/33:407f38549bb5be0a2545ba448d21940a201f1cca92e8117e42f3b30907d7d486", + "jitter/false/2/34:adbbc27204f00658207075d10787b1ae8d929226d70a5321e029635193dc432b", + "jitter/true/2/34:adbbc27204f00658207075d10787b1ae8d929226d70a5321e029635193dc432b" + ], + "freebuff-login": [ + "shrink-grow/false/1/21:92981723de5526414b99435b93b94ec87341b90acc7fea71b492e3ea6ae0d445", + "shrink-grow/false/1/23:92981723de5526414b99435b93b94ec87341b90acc7fea71b492e3ea6ae0d445", + "shrink-grow/false/2/33:f8c02c8afab034cc4fdcaa06b97764a32f6432d0c6d5f47b5f675fe6fd4cf67c", + "shrink-grow/true/1/21:44734d78cecaf5ae6203125b92dde9b61816b626efd6b643f78ce3f41c8b1b2d", + "shrink-grow/true/1/23:44734d78cecaf5ae6203125b92dde9b61816b626efd6b643f78ce3f41c8b1b2d", + "shrink-grow/true/2/33:f8c02c8afab034cc4fdcaa06b97764a32f6432d0c6d5f47b5f675fe6fd4cf67c", + "jitter/false/1/11:073b9a35e7a854d650d63f00dcc126f89656fab0f39951de83ff50fd564faab9", + "jitter/false/1/21:a046fe007dfe1161435083c3ed3448b4e74fa2203d692db99353b8df3c45f9a1", + "jitter/true/1/11:073b9a35e7a854d650d63f00dcc126f89656fab0f39951de83ff50fd564faab9", + "jitter/true/1/21:719241163ae1c3629c6a6e0739bb66324f620b8e285d176853d06d0ae26a6c00" + ], + "freebuff-ready": [], + "freebuff-trust": [] +} diff --git a/src/main/daemon/daemon-pty-buffer-snapshots.ts b/src/main/daemon/daemon-pty-buffer-snapshots.ts index f640483a39f..0bfb6cd44c9 100644 --- a/src/main/daemon/daemon-pty-buffer-snapshots.ts +++ b/src/main/daemon/daemon-pty-buffer-snapshots.ts @@ -184,6 +184,11 @@ export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl { this.markSessionDirty(id) } + async resetInputModes(id: string): Promise { + await this.client.request('resetInputModes', { sessionId: id }) + this.markSessionDirty(id) + } + acknowledgeDataEvent(_id: string, _charCount: number): void { // No flow control for daemon-backed terminals } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 2fbfd17a039..c3330e61826 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -162,6 +162,10 @@ export class DaemonPtyRouter implements IPtyProvider { await this.adapterFor(id).clearBuffer(id) } + async resetInputModes(id: string): Promise { + await this.adapterFor(id).resetInputModes(id) + } + async closeStartupQueryAuthority(id: string): Promise { return (await this.adapterFor(id).closeStartupQueryAuthority?.(id)) ?? 0 } diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index 5e799230fa1..afc7315abcd 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -289,7 +289,7 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences const snapshotFrame = reattachSnapshot.snapshotAnsi const snapshotPayload = snapshotPrefix + snapshotFrame - // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). + // Why kitty flags ride beside the payload, not inside it: renderers re-assert them in the replay epilogue, after the payload's screen switches (terminal-query-authority.md §kitty). // Why known `0` is no longer dropped: the pane tracker must be able to tell // "the app negotiated nothing" from "this reattach proved nothing". const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index ae007366dd0..9bd0d7963b6 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -129,6 +129,9 @@ export class DaemonRequestRouter { case 'clearScrollback': this.options.host.clearScrollback(request.payload.sessionId) return {} + case 'resetInputModes': + this.options.host.resetInputModes(request.payload.sessionId) + return {} case 'listSessions': return { sessions: this.options.host.listSessions() } case 'shutdownIfIdle': diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index f2e86abab28..363e9125b26 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -50,6 +50,7 @@ function createProvider( getCwd: vi.fn(async () => ''), getInitialCwd: vi.fn(async () => ''), clearBuffer: vi.fn(async () => {}), + resetInputModes: vi.fn(async () => {}), acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(async () => false), getForegroundProcess: vi.fn(async () => null), diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 2b50424ae83..6db8c80ac87 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -168,6 +168,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } clearBuffer = (id: string): Promise => this.providerFor(id).clearBuffer(id) + resetInputModes = (id: string): Promise => this.providerFor(id).resetInputModes(id) async closeStartupQueryAuthority(id: string): Promise { return (await this.providerFor(id).closeStartupQueryAuthority?.(id)) ?? 0 diff --git a/src/main/daemon/node-pty-error-hints.test.ts b/src/main/daemon/node-pty-error-hints.test.ts index f43951f05a6..361d8b73ba1 100644 --- a/src/main/daemon/node-pty-error-hints.test.ts +++ b/src/main/daemon/node-pty-error-hints.test.ts @@ -1,22 +1,12 @@ import { describe, expect, it } from 'vitest' +import { + LEGACY_PTY_ALLOCATION_HINT, + LEGACY_TERMINAL_PROCESS_LIMIT_HINT, + PTY_ALLOCATION_HINT, + TERMINAL_PROCESS_LIMIT_HINT +} from '../../shared/terminal-spawn-error-copy' import { addNodePtyRecoveryHint, parseNodePtyDiagnostic } from './node-pty-error-hints' -const PTY_ALLOCATION_HINT = [ - 'Your system cannot allocate any more pty devices.', - '', - 'Orca requires a pty device to launch a new terminal. This error is usually due to having too many terminal windows or terminal sessions open, either in Orca or another program.', - '', - 'Free up some pty devices and try again.' -].join('\n') - -const TERMINAL_PROCESS_LIMIT_HINT = [ - 'Your system cannot start another terminal process.', - '', - 'This is usually due to having too many terminal sessions or other processes running.', - '', - 'Close unused terminals or quit unused processes and try again.' -].join('\n') - describe('node-pty diagnostic error hints', () => { it('parses the native step and errno without dropping the original message', () => { const message = @@ -32,46 +22,71 @@ describe('node-pty diagnostic error hints', () => { const message = "node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave='/dev/ttys003'" - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints when the system cannot allocate a pty master', () => { const message = 'node-pty: posix_openpt failed: ENFILE (errno 23, Too many open files in system)' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints when macOS cannot configure a pty master device', () => { const message = 'node-pty: posix_openpt failed: errno (errno 6, Device not configured)' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints local wrapped spawn errors from pty allocation failures', () => { const message = 'Failed to spawn shell "/bin/zsh": node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave=\'/dev/ttys003\' (shell: /bin/zsh, cwd: /tmp, arch: arm64, platform: darwin 25.0.0, orca: 1.4.178). If this persists, please file an issue.' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) + }) + + it('keeps the whole recovery action on the first line', () => { + const message = + 'Failed to spawn shell "/bin/zsh": node-pty: posix_openpt failed: errno (errno 6, Device not configured) (shell: /bin/zsh). If this persists, please file an issue.' + + expect(addNodePtyRecoveryHint(message).split('\n')[0]).toBe(PTY_ALLOCATION_HINT) }) it('hints unstructured openpty allocation failures', () => { const message = 'Failed to spawn shell "/bin/bash": openpty(3) failed.' - expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${PTY_ALLOCATION_HINT}\n${message}`) }) it('hints when posix_spawn reports the per-user process limit', () => { const message = "node-pty: posix_spawn failed: EAGAIN (errno 35, Resource temporarily unavailable) - helper='/tmp/node-pty/spawn-helper'" - expect(addNodePtyRecoveryHint(message)).toBe(`${TERMINAL_PROCESS_LIMIT_HINT} ${message}`) + expect(addNodePtyRecoveryHint(message)).toBe(`${TERMINAL_PROCESS_LIMIT_HINT}\n${message}`) }) it('does not duplicate an existing recovery hint', () => { const message = "node-pty: open_slave failed: EMFILE (errno 24, Too many open files) - slave='/dev/ttys003'" - const hinted = `${PTY_ALLOCATION_HINT} ${message}` + const hinted = `${PTY_ALLOCATION_HINT}\n${message}` + + expect(addNodePtyRecoveryHint(hinted)).toBe(hinted) + }) + + it('does not duplicate hints from an older remote host', () => { + const ptyError = 'node-pty: open_slave failed: EMFILE (errno 24, Too many open files)' + const processError = 'node-pty: posix_spawn failed: EAGAIN (errno 35, Resource unavailable)' + + expect(addNodePtyRecoveryHint(`${LEGACY_PTY_ALLOCATION_HINT} ${ptyError}`)).toBe( + `${LEGACY_PTY_ALLOCATION_HINT} ${ptyError}` + ) + expect(addNodePtyRecoveryHint(`${LEGACY_TERMINAL_PROCESS_LIMIT_HINT} ${processError}`)).toBe( + `${LEGACY_TERMINAL_PROCESS_LIMIT_HINT} ${processError}` + ) + }) + + it('does not duplicate a legacy hint on unstructured openpty failures', () => { + const hinted = `${LEGACY_PTY_ALLOCATION_HINT} Failed to spawn shell "/bin/bash": openpty(3) failed.` expect(addNodePtyRecoveryHint(hinted)).toBe(hinted) }) diff --git a/src/main/daemon/node-pty-error-hints.ts b/src/main/daemon/node-pty-error-hints.ts index 022fb98c5be..182beb41a79 100644 --- a/src/main/daemon/node-pty-error-hints.ts +++ b/src/main/daemon/node-pty-error-hints.ts @@ -1,3 +1,10 @@ +import { + LEGACY_PTY_ALLOCATION_HINT, + LEGACY_TERMINAL_PROCESS_LIMIT_HINT, + PTY_ALLOCATION_HINT, + TERMINAL_PROCESS_LIMIT_HINT +} from '../../shared/terminal-spawn-error-copy' + export type NodePtyDiagnostic = { step: string errno: number @@ -25,22 +32,6 @@ const RESOURCE_EXHAUSTION_ERRNOS = new Set([ 35 // EAGAIN on macOS ]) -const PTY_ALLOCATION_HINT = [ - 'Your system cannot allocate any more pty devices.', - '', - 'Orca requires a pty device to launch a new terminal. This error is usually due to having too many terminal windows or terminal sessions open, either in Orca or another program.', - '', - 'Free up some pty devices and try again.' -].join('\n') - -const TERMINAL_PROCESS_LIMIT_HINT = [ - 'Your system cannot start another terminal process.', - '', - 'This is usually due to having too many terminal sessions or other processes running.', - '', - 'Close unused terminals or quit unused processes and try again.' -].join('\n') - export function parseNodePtyDiagnostic(message: string): NodePtyDiagnostic | null { const match = NODE_PTY_DIAGNOSTIC_RE.exec(message) ?? NODE_PTY_DIAGNOSTIC_ANYWHERE_RE.exec(message) @@ -70,18 +61,30 @@ export function getNodePtyRecoveryHint(diagnostic: NodePtyDiagnostic): string | return null } +function hasPtyAllocationHint(message: string): boolean { + return message.startsWith(PTY_ALLOCATION_HINT) || message.startsWith(LEGACY_PTY_ALLOCATION_HINT) +} + export function addNodePtyRecoveryHint(message: string): string { const diagnostic = parseNodePtyDiagnostic(message) if (!diagnostic) { - if (GENERIC_PTY_ALLOCATION_RE.test(message) && !message.startsWith(PTY_ALLOCATION_HINT)) { - return `${PTY_ALLOCATION_HINT} ${message}` + if (GENERIC_PTY_ALLOCATION_RE.test(message) && !hasPtyAllocationHint(message)) { + return `${PTY_ALLOCATION_HINT}\n${message}` } return message } const hint = getNodePtyRecoveryHint(diagnostic) - if (hint && message.startsWith(hint)) { + if ( + !hint || + message.startsWith(hint) || + (hint === PTY_ALLOCATION_HINT && hasPtyAllocationHint(message)) || + (hint === TERMINAL_PROCESS_LIMIT_HINT && message.startsWith(LEGACY_TERMINAL_PROCESS_LIMIT_HINT)) + ) { return message } - return hint ? `${hint} ${message}` : message + // Older clients need both stale-daemon markers before their first-line IPC truncation. + const separator = + hint === PTY_ALLOCATION_HINT || hint === TERMINAL_PROCESS_LIMIT_HINT ? '\n' : ' ' + return `${hint}${separator}${message}` } diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 6fdf7b70da6..c8316bb383a 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -22,6 +22,7 @@ import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables } from '../../../shared/windows-environment-expansion' +import { applyScrubSafeAgentEnvAliases } from '../../../shared/agent-hook-scrub-safe-env' import type { TuiAgent } from '../../../shared/tui-agent' import type { PtySubprocessOptions } from '../pty-subprocess' @@ -188,6 +189,9 @@ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record = { less: 6, nano: 2, opencode: 5, + // Shrink leaves the cursor one column short; also present in the pre-Qoder serializer. + 'qoder-no-account': 2, + 'qoder-ready': 2, // Codex 0.157 header border restores with an extra attribute bit (STA-8628 fixtures). 'codex-0157-config-override-embedded-warning': 22, 'codex-0157-effort-override-embedded-warning': 4, 'codex-0157-no-daemon-effort-override': 16, 'codex-0157-plain-ready': 18, - 'claude-dialog-trust-workspace-answered': 13 + 'claude-dialog-trust-workspace-answered': 13, + // DSH-TUI's whale intro paints whole rows of 24-bit background, and every one of this + // transcript's divergences is the same shape: `visible-grid row=0`, a true-colour + // background that the round trip does not restore to default. Verified as upstream, not a + // regression, by replaying it against the previous build + // (`build-serialize-addon-at-ref.mjs --ref origin/main`): I1 and I3 both hold. + 'dsh-tui-ready-no-key': 10 } +// Exact resize checkpoints and full GridDiff hashes from base 6835b9b4e3ea, not this branch. +const FREEBUFF_BASELINE: Record = JSON.parse( + readFileSync(join(__dirname, '__fixtures__/freebuff-serialize-baseline.json'), 'utf8') +) + type Transcript = { name: string; data: string; cols: number; rows: number } type Schedule = 'none' | 'shrink' | 'shrink-grow' | 'jitter' const SCHEDULES: readonly Schedule[] = ['none', 'shrink', 'shrink-grow', 'jitter'] @@ -133,6 +148,7 @@ describe('serialize round trip over captured PTY transcripts', () => { async (_name, transcript) => { const counts: Partial> = {} const blocking: string[] = [] + const failureSignatures: string[] = [] for (const schedule of SCHEDULES) { for (const conpty of [false, true]) { for (let seed = 1; seed <= SEEDS; seed++) { @@ -147,6 +163,14 @@ describe('serialize round trip over captured PTY transcripts', () => { ` ${transcript.name} ${schedule} conpty=${conpty} seed=${seed}: ${d.stage} row=${d.row} ${JSON.stringify(d.expected)?.slice(0, 160)} -> ${JSON.stringify(d.actual)?.slice(0, 160)}` ) } + if (check.gridDiff.new) { + const signature = createHash('sha256') + .update(JSON.stringify(check.gridDiff.new)) + .digest('hex') + failureSignatures.push( + `${schedule}/${conpty}/${seed}/${check.stepIndex}:${signature}` + ) + } for (const verdict of verdicts(check, serializers.length > 1)) { counts[verdict] = (counts[verdict] ?? 0) + 1 if (verdict === 'i1-bytes-differ' || verdict === 'regression') { @@ -163,7 +187,9 @@ describe('serialize round trip over captured PTY transcripts', () => { console.log(`${transcript.name} ${JSON.stringify(counts)}`) } expect(blocking).toEqual([]) - if (!OLD_ADDON_PATH && SEEDS === 2) { + if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) { + expect(failureSignatures).toEqual(FREEBUFF_BASELINE[transcript.name] ?? []) + } else if (!OLD_ADDON_PATH && SEEDS === 2) { expect(counts['new-fail'] ?? 0).toBe(KNOWN_PREEXISTING_I2_FAILURES[transcript.name] ?? 0) } }, diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index 507ca01a927..04b3f773bda 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -125,6 +125,16 @@ export class SessionOutputPlane { }) } + /** Grounds the emulator and the cold-restore records without a client + * broadcast; attached renderers ground themselves (Reset Terminal). */ + applyInputModeGround(ground: string): void { + if (this.disposed) { + return + } + this.emulator.write(ground) + this.record({ kind: 'output', data: ground }) + } + isCursorOnEmptyPromptLine(): boolean { return this.emulator.isCursorOnEmptyPromptLine() } diff --git a/src/main/daemon/session-reset-input-modes.test.ts b/src/main/daemon/session-reset-input-modes.test.ts new file mode 100644 index 00000000000..ea1aa7e7351 --- /dev/null +++ b/src/main/daemon/session-reset-input-modes.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { Session } from './session' +import type { SubprocessHandle } from './session-subprocess-handle' + +function createSession() { + let onData: ((data: string) => void) | null = null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Session reads only these members. + const handle = { + pid: 999, + getForegroundProcess: () => null, + confirmShellForeground: vi.fn(async () => false), + write: () => {}, + resize: () => {}, + pause: () => {}, + resume: () => {}, + kill: () => {}, + forceKill: () => {}, + signal: () => {}, + terminateOwnedTree: () => 'unavailable' as const, + onData(cb: (data: string) => void) { + onData = cb + }, + onExit() {}, + dispose: () => {} + } as unknown as SubprocessHandle + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the omitted options are optional. + const session = new Session({ + sessionId: 'reset', + cols: 80, + rows: 24, + subprocess: handle, + shellReadySupported: false + } as never) + const received: string[] = [] + session.attachClient({ onData: (data: string) => received.push(data), onExit: () => {} }) + return { session, received, emit: (data: string) => onData?.(data) } +} + +async function readModes(session: Session) { + await session.settleShellOwnershipConfirmation() + return session.getSnapshot()!.modes +} + +describe('Session.resetInputModes', () => { + it('grounds modes an app left armed without a command end, and the next snapshot carries it', async () => { + const { session, received, emit } = createSession() + // A crashed app's arming with no OSC 133;D the barrier could ground at. + emit('prompt$ app\r\n\x1b[>31u\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?1h') + const armed = await readModes(session) + expect(armed).toMatchObject({ + kittyKeyboardFlags: 31, + mouseTracking: true, + bracketedPaste: true + }) + session.takePendingOutput(false) + const broadcast = received.length + + session.resetInputModes() + + expect(await readModes(session)).toMatchObject({ + kittyKeyboardFlags: 0, + mouseTracking: false, + sgrMouseMode: false, + bracketedPaste: false, + applicationCursor: false, + alternateScreen: false + }) + // Clients ground themselves; a zero-raw span here would be dropped or duplicated. + expect(received).toHaveLength(broadcast) + const records = session.takePendingOutput(false)!.records + expect(records).toEqual([{ kind: 'output', data: expect.stringContaining('\x1b[<99u') }]) + session.dispose() + }) + + it('keeps focus reporting the terminal host armed for the pane', async () => { + const { session, emit } = createSession() + // ConPTY arms ?1004h before any shell marker. + emit('\x1b[?1004hprompt$ ') + await readModes(session) + session.takePendingOutput(false) + + session.resetInputModes() + + const [record] = session.takePendingOutput(false)!.records + expect(record).toMatchObject({ kind: 'output' }) + expect(record?.kind === 'output' && record.data).not.toContain('\x1b[?1004l') + session.dispose() + }) +}) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index e69bec7c5b1..7d113665292 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -146,13 +146,9 @@ export class Session { // Daemon POSIX PTYs need the local provider's cooked-echo containment (#13137). // DA1/CPR stay immediate unless an echo-risk reply is already held (#13892, #15559). - if (this.startupIngress.answerLiveQueryReply(data)) { - return - } - - // Why: keep queuing during the post-ready flush-gate window ('ready' but not yet flushed); a - // direct write would race fresh input ahead of the buffered startup command. - if (this.shellReady.tryEnqueue(data)) { + // Why the queue: keep queuing during the post-ready flush-gate window ('ready' but not yet + // flushed); a direct write would race fresh input ahead of the buffered startup command. + if (this.startupIngress.answerLiveQueryReply(data) || this.shellReady.tryEnqueue(data)) { return } @@ -277,6 +273,10 @@ export class Session { this.output.clearScrollback(this.subprocess, this.shellReady.isGatingWrites) } + resetInputModes(): void { + this.output.applyInputModeGround(this.recoveryBarrier.groundInputModes()) + } + prepareForFinalSnapshot(): string { const held = this.shellReady.releaseHeldBytes() this.startupIngress.snapshotBarrier() diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 6e8d5ceee81..d5669618c7a 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -277,6 +277,10 @@ export class TerminalHost { getAliveTerminalHostSession(this.sessions, sessionId).clearScrollback() } + resetInputModes(sessionId: string): void { + getAliveTerminalHostSession(this.sessions, sessionId).resetInputModes() + } + // Why: null-not-throw — checkpoint is best-effort against a session that may have just exited. getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { return getTerminalHostSnapshot(this.sessions.get(sessionId), opts) diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts index ac4e34897b6..8537de08961 100644 --- a/src/main/daemon/terminal-mode-rehydrate-sequences.ts +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -1,11 +1,9 @@ import type { TerminalModes } from './types' import { RESET_GRAPHIC_RENDITION } from '../../shared/terminal-mode-reset-profiles' -// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and -// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C -// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for -// emulator re-seed parity only; a re-seeded emulator answers ?0u and -// protocol-conformant programs re-push. +// Why no kitty flags here: renderers re-assert the snapshot's kitty flags +// (carried beside the payload) in their replay epilogue, after these screen +// switches. A re-seeded emulator uses modes.kittyKeyboardFlags directly. export function buildRehydrateSequences(modes: TerminalModes): string { const seqs: string[] = [] if (modes.alternateScreen) { diff --git a/src/main/daemon/terminal-shell-recovery-barrier.ts b/src/main/daemon/terminal-shell-recovery-barrier.ts index 6fe5832dcf1..d8a29d4b4b4 100644 --- a/src/main/daemon/terminal-shell-recovery-barrier.ts +++ b/src/main/daemon/terminal-shell-recovery-barrier.ts @@ -86,6 +86,13 @@ export class TerminalShellRecoveryBarrier { return this.scanner.owner } + /** Reset Terminal: grounds the lifecycle model now and returns the bytes for + * the host's other models. Not released downstream: a zero-raw span is dropped + * by credit-windowed delivery and snapshot-seq dedup, so each client grounds itself. */ + groundInputModes(): string { + return this.scanner.groundProcessBoundary() + } + /** Answers a paired runtime's ownership question from the barrier's settled * state. The barrier scans bytes before any consumer receives them, so its * verdict is never behind the caller's parse position — a fresh process diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index d8d88fab033..2ef4dfeb117 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -206,9 +206,10 @@ export type GetCwdRequest = { } } -export type ClearScrollbackRequest = { +// Why resetInputModes is a type, not a clear flag: an older daemon rejects it instead of clearing. +export type TerminalBufferActionRequest = { id: string - type: 'clearScrollback' + type: 'clearScrollback' | 'resetInputModes' payload: { sessionId: string } @@ -321,7 +322,7 @@ export type DaemonRequest = | InspectProcessRequest | ConfirmForegroundProcessRequest | ConfirmShellForegroundRequest - | ClearScrollbackRequest + | TerminalBufferActionRequest | ShutdownRequest | PingRequest | SystemResolverHealthRequest diff --git a/src/main/dsh/dsh-home-patch.test.ts b/src/main/dsh/dsh-home-patch.test.ts new file mode 100644 index 00000000000..53ab8986715 --- /dev/null +++ b/src/main/dsh/dsh-home-patch.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { + applyManagedDshPatch, + findManagedDshPatchRegion, + readManagedDshHooksConfigPath, + removeManagedDshPatch +} from './dsh-home-patch' + +const HOOKS_PATH = '/home/dev/.orca/agent-hooks/dsh-hooks.json' + +/** applyManagedDshPatch returns null only for files it refuses to edit; these cases expect an edit. */ +function applyOrFail(text: string, hooksPath = HOOKS_PATH): string { + const next = applyManagedDshPatch(text, hooksPath) + if (next === null) { + throw new Error('expected the patch file to be editable') + } + return next +} + +// The body DSH writes into a freshly initialized patch file. +const PRISTINE = [ + '# Your patch layer for this dsh profile, applied after every bundle layer:', + '# a top-level YAML array of loader patch entries (id-targeted config', + '# overrides, disables, and insert lists; `!!js` expressions allowed).', + '[]', + '' +].join('\n') + +const USER_ROWS = ['- id: llm-deepseek', ' config:', " apiKeyEnv: 'MY_KEY'", ''].join('\n') + +describe('applyManagedDshPatch', () => { + it('creates the managed block in an empty file', () => { + const text = applyOrFail('') + expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH) + expect(text).toContain("name: '@deepseek-ai/dsh-hooks-claude-code'") + expect(text.endsWith('\n')).toBe(true) + }) + + it('replaces the empty flow sequence DSH ships, keeping its comments', () => { + const text = applyOrFail(PRISTINE) + // `- item` after `[]` is a YAML parse error, so the `[]` token has to go. + expect(text).not.toMatch(/^\[]$/m) + expect(text).toContain('# Your patch layer for this dsh profile') + expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH) + }) + + it('appends after user rows without touching them', () => { + const text = applyOrFail(USER_ROWS) + expect(text.startsWith(USER_ROWS.trimEnd())).toBe(true) + expect(readManagedDshHooksConfigPath(text)).toBe(HOOKS_PATH) + }) + + it('rewrites its own block in place rather than stacking copies', () => { + const once = applyOrFail(USER_ROWS, '/old/path.json') + const twice = applyOrFail(once) + expect(twice.match(/orca-managed-dsh-hooks \(managed by Orca/g)).toHaveLength(1) + expect(readManagedDshHooksConfigPath(twice)).toBe(HOOKS_PATH) + expect(twice).not.toContain('/old/path.json') + }) + + it('is idempotent', () => { + const once = applyOrFail(PRISTINE) + expect(applyOrFail(once)).toBe(once) + }) + + it('treats `[] # comment` as the empty document it is, keeping the comment', () => { + // The exact-match check missed this and appended `- insert:` after the flow sequence. + const text = '[] # keep empty\n' + const next = applyOrFail(text) + expect(next).not.toMatch(/^\s*\[]/m) + expect(next).toContain('# keep empty') + expect(readManagedDshHooksConfigPath(next)).toBe(HOOKS_PATH) + }) + + it.each([ + '- id: llm-deepseek\n config: {}\n', // block sequence — editable + '\n', + '# only a comment\n' + ])('still edits %j', (text) => { + expect(applyManagedDshPatch(text, HOOKS_PATH)).not.toBeNull() + }) + + it.each(['[{ id: llm-deepseek }]\n', '[\n { id: a },\n { id: b }\n]\n'])( + 'refuses to append after the non-empty flow sequence %j', + (text) => { + // YAML forbids a block entry after a flow sequence: appending would leave DSH unable + // to parse the user's own layer either, so there is no safe in-place edit. + expect(applyManagedDshPatch(text, HOOKS_PATH)).toBeNull() + } + ) + + it('quotes a path containing a single quote', () => { + const awkward = "/home/o'brien/.orca/agent-hooks/dsh-hooks.json" + expect(readManagedDshHooksConfigPath(applyOrFail('', awkward))).toBe(awkward) + }) +}) + +describe('removeManagedDshPatch', () => { + it('restores the empty flow sequence when nothing else remains', () => { + const installed = applyOrFail(PRISTINE) + const { text, changed } = removeManagedDshPatch(installed) + expect(changed).toBe(true) + // Without this the file would come back as an unparseable empty document. + expect(text.trimEnd().endsWith('[]')).toBe(true) + expect(text).toContain('# Your patch layer for this dsh profile') + }) + + it('leaves user rows alone and adds no [] when they remain', () => { + const installed = applyOrFail(USER_ROWS) + const { text } = removeManagedDshPatch(installed) + expect(text.trimEnd()).toBe(USER_ROWS.trimEnd()) + }) + + it('reports no change when the file carries no managed block', () => { + expect(removeManagedDshPatch(USER_ROWS)).toEqual({ text: USER_ROWS, changed: false }) + }) +}) + +describe('findManagedDshPatchRegion', () => { + const ORPHAN_START = '# >>> orca-managed-dsh-hooks (managed by Orca; do not edit) >>>' + + it('fails closed on a truncated region rather than guessing its extent', () => { + // Splicing a guessed end marker would delete the user rows that follow. + const truncated = `${ORPHAN_START}\n${USER_ROWS}` + expect(findManagedDshPatchRegion(truncated)).toBeNull() + expect(removeManagedDshPatch(truncated).changed).toBe(false) + }) + + it('never pairs an orphan start with a later block\u2019s end', () => { + // The data-loss shape: an interrupted write leaves an orphan start above the user's + // rows, and the next install appends a complete block below them. Pairing the orphan + // with the new end marker would make the region cover the user's rows, so install + // (rewrite) and remove (strip) would both delete them. + const truncated = `${ORPHAN_START}\n${USER_ROWS}` + const installed = applyOrFail(truncated) + expect(installed).toContain('apiKeyEnv') + + const region = findManagedDshPatchRegion(installed) + expect(region).not.toBeNull() + const covered = installed.split('\n').slice(region?.startLine ?? 0, (region?.endLine ?? 0) + 1) + expect(covered.join('\n')).not.toContain('apiKeyEnv') + + // Both mutating paths must leave the user's rows intact, twice over. + expect(applyOrFail(installed)).toContain('apiKeyEnv') + expect(removeManagedDshPatch(installed).text).toContain('apiKeyEnv') + }) +}) diff --git a/src/main/dsh/dsh-home-patch.ts b/src/main/dsh/dsh-home-patch.ts new file mode 100644 index 00000000000..723d030e214 --- /dev/null +++ b/src/main/dsh/dsh-home-patch.ts @@ -0,0 +1,195 @@ +/** + * Orca's managed block inside `$DSH_HOME/cordis.patch.yml`. + * + * That file is a hand-editable top-level YAML sequence of loader patch entries, and no + * YAML library is vendored in the main process, so Orca manages only its own + * marker-delimited region: install rewrites the region, remove strips it, and everything + * outside the markers is copied through byte for byte. Appending sequence entries to a + * block sequence is always valid YAML, so the region can live at the end of any file. + * + * The one shape that is not append-safe is an empty *flow* sequence (`[]`), which is what + * DSH writes into a freshly initialized patch file. `- item` after `[]` is a parse error, + * so that token is dropped when the managed block is added and restored when it is the + * last thing removed — otherwise the file would come back as an unparseable empty + * document. + */ + +const START_MARKER = '# >>> orca-managed-dsh-hooks (managed by Orca; do not edit) >>>' +const END_MARKER = '# <<< orca-managed-dsh-hooks <<<' + +/** The loader row id Orca owns. A patch row is addressed by id, so this must be stable. */ +const MANAGED_ROW_ID = 'orca-agent-hooks' + +const EMPTY_FLOW_SEQUENCE = '[]' + +export type ManagedDshPatchRegion = { startLine: number; endLine: number } + +function splitLines(text: string): string[] { + return text.split('\n') +} + +/** Locate the managed region, or null when the file carries none. */ +export function findManagedDshPatchRegion(text: string): ManagedDshPatchRegion | null { + // Why the NEAREST preceding start, not the first one: an interrupted write can leave an + // orphan start marker with no end. Pairing that orphan with a LATER block's end marker + // makes the region swallow every row in between — so the next install (which rewrites the + // region) or remove (which strips it) would delete the user's own rows. Walking forward + // and resetting the candidate on each start keeps an orphan un-paired, which leaves it as + // an inert comment line rather than a deletion range. + let startLine = -1 + for (const [index, line] of splitLines(text).entries()) { + const trimmed = line.trim() + if (trimmed === START_MARKER) { + startLine = index + } else if (trimmed === END_MARKER && startLine !== -1) { + return { startLine, endLine: index } + } + } + return null +} + +function buildManagedBlock(managedHooksPath: string): string[] { + return [ + START_MARKER, + '- insert:', + ` - id: ${MANAGED_ROW_ID}`, + " name: '@deepseek-ai/dsh-hooks-claude-code'", + ' config:', + ` configPath: ${quoteYamlScalar(managedHooksPath)}`, + END_MARKER + ] +} + +/** Single-quoted YAML scalar: the only escape inside one is a doubled quote. */ +function quoteYamlScalar(value: string): string { + return `'${value.replaceAll("'", "''")}'` +} + +function unquoteYamlScalar(value: string): string { + const trimmed = value.trim() + if (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2) { + return trimmed.slice(1, -1).replaceAll("''", "'") + } + if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) { + return trimmed.slice(1, -1) + } + return trimmed +} + +/** The `configPath` Orca's managed region currently points at, if any. */ +export function readManagedDshHooksConfigPath(text: string): string | undefined { + const region = findManagedDshPatchRegion(text) + if (!region) { + return undefined + } + for (const line of splitLines(text).slice(region.startLine + 1, region.endLine)) { + const match = /^\s*configPath:\s*(.+?)\s*$/.exec(line) + if (match) { + return unquoteYamlScalar(match[1]) + } + } + return undefined +} + +function stripRegion(lines: string[], region: ManagedDshPatchRegion): string[] { + return [...lines.slice(0, region.startLine), ...lines.slice(region.endLine + 1)] +} + +function isBlank(line: string): boolean { + return line.trim().length === 0 +} + +function isComment(line: string): boolean { + return line.trim().startsWith('#') +} + +function documentBody(lines: readonly string[]): readonly string[] { + return lines.filter((line) => !isBlank(line) && !isComment(line)) +} + +/** Strips a trailing `# …` so `[] # keep empty` reads as the empty sequence it is. */ +function withoutTrailingComment(line: string): string { + const hash = line.indexOf('#') + return (hash === -1 ? line : line.slice(0, hash)).trim() +} + +/** True when the body is the empty flow sequence, with or without a trailing comment. */ +function isEmptyFlowDocument(lines: readonly string[]): boolean { + const body = documentBody(lines) + return body.length === 1 && withoutTrailingComment(body[0]) === EMPTY_FLOW_SEQUENCE +} + +/** + * True when Orca cannot append its block to this file: the body is a NON-EMPTY flow + * sequence (`[a, b]`, or one spread over lines). + * + * Why it matters: YAML forbids a block entry after a flow sequence, so appending Orca's + * `- insert:` would produce a file DSH cannot parse — losing the user's own patch layer as + * well as Orca's hooks. There is no safe in-place edit, so install refuses instead. + * + * Exported because status has to report the same refusal on every read, not just on the + * install that first hit it. + */ +export function isDshPatchFileUnappendable(text: string): boolean { + const lines = splitLines(text) + const body = documentBody(lines) + return body.length > 0 && body[0].trimStart().startsWith('[') && !isEmptyFlowDocument(lines) +} + +function withoutTrailingBlanks(lines: readonly string[]): readonly string[] { + const end = lines.findLastIndex((line) => !isBlank(line)) + return lines.slice(0, end + 1) +} + +function joinPreservingTrailingNewline(lines: readonly string[]): string { + const text = lines.join('\n') + return text.endsWith('\n') || text.length === 0 ? text : `${text}\n` +} + +/** + * Install (or refresh) Orca's managed region so the DSH hook bridge reads + * `managedHooksPath`. Everything outside the markers is preserved. + * + * Returns null when the file cannot be edited safely — see isNonEmptyFlowDocument. The + * caller reports that; it must never write a file DSH would then fail to parse. + */ +export function applyManagedDshPatch(text: string, managedHooksPath: string): string | null { + const lines = splitLines(text) + const block = buildManagedBlock(managedHooksPath) + const region = findManagedDshPatchRegion(text) + if (region) { + return joinPreservingTrailingNewline([ + ...lines.slice(0, region.startLine), + ...block, + ...lines.slice(region.endLine + 1) + ]) + } + if (isDshPatchFileUnappendable(text)) { + return null + } + // Why dropped: `- item` after `[]` is a parse error, and an empty sequence has nothing to + // preserve. Only the token goes — a trailing comment on that line stays. + const kept = isEmptyFlowDocument(lines) + ? lines.map((line) => + withoutTrailingComment(line) === EMPTY_FLOW_SEQUENCE + ? line.slice(line.indexOf(EMPTY_FLOW_SEQUENCE) + EMPTY_FLOW_SEQUENCE.length) + : line + ) + : lines + return joinPreservingTrailingNewline([...withoutTrailingBlanks(kept), ...block]) +} + +/** Strip Orca's managed region, restoring `[]` when nothing else is left. */ +export function removeManagedDshPatch(text: string): { text: string; changed: boolean } { + const region = findManagedDshPatchRegion(text) + if (!region) { + return { text, changed: false } + } + const kept = withoutTrailingBlanks(stripRegion(splitLines(text), region)) + // Why restore `[]`: a document of comments alone is not a valid entry list, so removing + // Orca's block must not leave DSH a file it cannot parse. + const body = kept.every((line) => isBlank(line) || isComment(line)) + ? [...kept, EMPTY_FLOW_SEQUENCE] + : kept + return { text: joinPreservingTrailingNewline(body), changed: true } +} diff --git a/src/main/dsh/hook-service.test.ts b/src/main/dsh/hook-service.test.ts new file mode 100644 index 00000000000..331ecd97888 --- /dev/null +++ b/src/main/dsh/hook-service.test.ts @@ -0,0 +1,200 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { DshHookService } from './hook-service' +import { DSH_HOOK_EVENTS } from './hook-settings' + +// Why: getSharedManagedScriptPath() writes under homedir()/.orca and the patch layer +// resolves via DSH_HOME ?? ~/.dsh. Point the home env at a temp dir and clear DSH_HOME so +// install/remove never touches the real ~/.orca or a developer's own DSH home. +// Why both names: os.homedir() reads $HOME on POSIX and %USERPROFILE% on Windows, and this +// file asserts the Windows script name too — setting only HOME would let a Windows run edit +// the developer's real home. +let home: string +let originalHome: string | undefined +let originalUserProfile: string | undefined +let originalDshHome: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-dsh-hook-')) + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + originalDshHome = process.env.DSH_HOME + process.env.HOME = home + process.env.USERPROFILE = home + delete process.env.DSH_HOME +}) + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + if (originalDshHome === undefined) { + delete process.env.DSH_HOME + } else { + process.env.DSH_HOME = originalDshHome + } + rmSync(home, { recursive: true, force: true }) +}) + +const configPath = (): string => join(home, '.dsh', 'cordis.patch.yml') +const managedHooksPath = (): string => join(home, '.orca', 'agent-hooks', 'dsh-hooks.json') +const scriptPath = (): string => + join(home, '.orca', 'agent-hooks', process.platform === 'win32' ? 'dsh-hook.cmd' : 'dsh-hook.sh') + +function readManagedHooks(): { hooks: Record } { + const parsed: { hooks: Record } = JSON.parse( + readFileSync(managedHooksPath(), 'utf-8') + ) + return parsed +} + +describe('DshHookService', () => { + it('reports not_installed before install, with no DSH home on disk', () => { + expect(new DshHookService().getStatus().state).toBe('not_installed') + }) + + it('installs the patch block, the managed hooks file, and the script', () => { + const status = new DshHookService().install() + expect(status.state).toBe('installed') + expect(status.managedHooksPresent).toBe(true) + expect(status.configPath).toBe(configPath()) + + const patch = readFileSync(configPath(), 'utf-8') + expect(patch).toContain("name: '@deepseek-ai/dsh-hooks-claude-code'") + expect(patch).toContain(`configPath: '${managedHooksPath()}'`) + + // Exactly the events DSH's bridge can fire — registering more would register nothing + // for them and leave the status permanently `partial`. + expect(Object.keys(readManagedHooks().hooks).sort()).toEqual([...DSH_HOOK_EVENTS].sort()) + expect(readFileSync(scriptPath(), 'utf-8').length).toBeGreaterThan(0) + }) + + it('posts to the dsh hook route', () => { + new DshHookService().install() + expect(readFileSync(scriptPath(), 'utf-8')).toContain('/hook/dsh') + }) + + it('restores the pane identity DSH scrubs before anything reads it', () => { + // DSH drops env names containing KEY/TOKEN, so the script has to recover ORCA_PANE_KEY + // and ORCA_AGENT_LAUNCH_TOKEN from their aliases before the guard or the spool run. + new DshHookService().install() + const script = readFileSync(scriptPath(), 'utf-8') + const restoreAt = script.indexOf('ORCA_AGENT_PANE') + const guardAt = script.indexOf('ORCA_AGENT_HOOK_PORT') + expect(restoreAt).toBeGreaterThan(-1) + expect(script).toContain('ORCA_AGENT_LAUNCH') + expect(restoreAt).toBeLessThan(guardAt) + }) + + it('is idempotent', () => { + const service = new DshHookService() + service.install() + const first = readFileSync(configPath(), 'utf-8') + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath(), 'utf-8')).toBe(first) + }) + + it('preserves an existing user patch layer through install and remove', () => { + const userRows = ['- id: llm-deepseek', ' config:', ' thinking: disabled', ''].join('\n') + mkdirSync(join(home, '.dsh'), { recursive: true }) + writeFileSync(configPath(), userRows, 'utf-8') + + const service = new DshHookService() + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath(), 'utf-8')).toContain('thinking: disabled') + + expect(service.remove().state).toBe('not_installed') + expect(readFileSync(configPath(), 'utf-8').trimEnd()).toBe(userRows.trimEnd()) + }) + + it('removes the managed hooks file along with the block', () => { + const service = new DshHookService() + service.install() + service.remove() + expect(() => readFileSync(managedHooksPath(), 'utf-8')).toThrow() + expect(service.getStatus().state).toBe('not_installed') + }) + + it('reports partial when an event loses its managed hook', () => { + const service = new DshHookService() + service.install() + const managed = readManagedHooks() + delete managed.hooks.Stop + writeFileSync(managedHooksPath(), JSON.stringify(managed, null, 2), 'utf-8') + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Stop') + }) + + it('reports not_installed when the block points somewhere else', () => { + const service = new DshHookService() + service.install() + writeFileSync( + configPath(), + readFileSync(configPath(), 'utf-8').replace(managedHooksPath(), '/somewhere/else.json'), + 'utf-8' + ) + const status = service.getStatus() + expect(status.state).toBe('not_installed') + expect(status.detail).toContain('/somewhere/else.json') + }) + + // Why POSIX-only: on Windows chmod toggles the read-only attribute, so `mode & 0o777` + // reads 0o666 for any writable file and the assertion cannot mean what it says. + it.skipIf(process.platform === 'win32')('keeps an owner-only patch file owner-only', () => { + // CWE-732: the temp+rename replacement must not widen the file to the umask default. + const userRows = '- id: llm-deepseek\n config: {}\n' + mkdirSync(join(home, '.dsh'), { recursive: true }) + writeFileSync(configPath(), userRows, 'utf-8') + chmodSync(configPath(), 0o600) + + expect(new DshHookService().install().state).toBe('installed') + expect(statSync(configPath()).mode & 0o777).toBe(0o600) + }) + + it('refuses a flow-style patch file instead of corrupting it', () => { + // Appending a block entry after `[…]` is invalid YAML — DSH would then fail to load the + // user's own layer as well as Orca's hooks, so install must change nothing. + const flow = '[{ id: llm-deepseek }]\n' + mkdirSync(join(home, '.dsh'), { recursive: true }) + writeFileSync(configPath(), flow, 'utf-8') + + const service = new DshHookService() + const installed = service.install() + expect(installed.state).toBe('error') + expect(installed.detail).toContain('flow-style sequence') + expect(readFileSync(configPath(), 'utf-8')).toBe(flow) + + // Why re-read: a later status poll must keep saying why, not decay to a bare + // `not_installed` that gives the user nothing to act on. + const polled = service.getStatus() + expect(polled.state).toBe('error') + expect(polled.detail).toContain('flow-style sequence') + }) + + it('honours DSH_HOME', () => { + const dshHome = join(home, 'custom-dsh-home') + process.env.DSH_HOME = dshHome + const status = new DshHookService().install() + expect(status.configPath).toBe(join(dshHome, 'cordis.patch.yml')) + expect(status.state).toBe('installed') + }) +}) diff --git a/src/main/dsh/hook-service.ts b/src/main/dsh/hook-service.ts new file mode 100644 index 00000000000..0767f686f24 --- /dev/null +++ b/src/main/dsh/hook-service.ts @@ -0,0 +1,253 @@ +import { mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' +import type { SFTPWrapper } from 'ssh2' + +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { + buildWindowsAgentHookCurlPostCommand, + writeHooksJson, + writeManagedScript +} from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' +import { + readTextFileRemote, + writeManagedScriptRemote, + writeTextFileRemoteAtomic +} from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' +import { + applyManagedDshPatch, + isDshPatchFileUnappendable, + readManagedDshHooksConfigPath, + removeManagedDshPatch +} from './dsh-home-patch' +import { + buildDshManagedHooksFile, + DSH_HOOK_EVENTS, + getDshConfigPath, + getDshManagedCommand, + getDshManagedCommandMatcher, + getDshManagedHooksPath, + getDshManagedScriptPath, + getDshRemoteConfigPath, + getDshRemoteManagedCommand, + getDshRemoteManagedHooksPath, + readManagedDshHookEvents +} from './hook-settings' + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: same scrub as POSIX — restore the canonical names from their aliases first. + 'if not defined ORCA_PANE_KEY if defined ORCA_AGENT_PANE set "ORCA_PANE_KEY=%ORCA_AGENT_PANE%"', + 'if not defined ORCA_AGENT_LAUNCH_TOKEN if defined ORCA_AGENT_LAUNCH set "ORCA_AGENT_LAUNCH_TOKEN=%ORCA_AGENT_LAUNCH%"', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookCurlPostCommand('dsh'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + // Why first: DSH's shell executor drops every env var whose NAME contains KEY, TOKEN, + // SECRET or PASSWORD before the hook starts, which takes ORCA_PANE_KEY and + // ORCA_AGENT_LAUNCH_TOKEN with it. Orca mirrors both onto scrub-safe aliases at spawn + // (see agent-hook-scrub-safe-env.ts); restore the canonical names from them so every + // line below — including the shared spool and post builders — is unchanged. + ': "${ORCA_PANE_KEY:=${ORCA_AGENT_PANE:-}}"', + ': "${ORCA_AGENT_LAUNCH_TOKEN:=${ORCA_AGENT_LAUNCH:-}}"', + 'export ORCA_PANE_KEY ORCA_AGENT_LAUNCH_TOKEN', + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('dsh'), + // Why: the endpoint file holds the live port/token; a PTY that outlived an Orca + // restart carries stale env, so source it to reach the new server. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + ...buildPosixAgentHookPostCommand('dsh').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), + 'exit 0', + '' + ].join('\n') +} + +/** '' when the file is absent (both are created lazily), null when it exists but cannot be read. */ +function readTextOrAbsent(path: string): string | null { + try { + return readFileSync(path, 'utf-8') + } catch (error) { + return isDefinitiveAbsence(error) ? '' : null + } +} + +/** null for anything that is not parseable JSON; the caller reads that as "no events". */ +function parseJsonOrNull(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return null + } +} + +function writePatchText(configPath: string, text: string): void { + mkdirSync(dirname(configPath), { recursive: true }) + // Why writeHooksJson: it owns the temp+rename and the rolling .bak this file needs too. + // Why preserveMode: an owner-only patch file must not widen to the umask default on rewrite. + writeHooksJson(configPath, {}, { serialized: text, preserveMode: true }) +} + +/** Why one constant: status has to say exactly what install said, on every later read. */ +const FLOW_STYLE_DETAIL = + 'The DSH home patch is a flow-style sequence ([…]); rewrite it as a block sequence (one `- ` entry per line) so Orca can add its hooks without breaking it' + +function status( + configPath: string, + state: AgentHookInstallState, + detail: string | null, + managedHooksPresent = false +): AgentHookInstallStatus { + return { agent: 'dsh', state, configPath, managedHooksPresent, detail } +} + +function buildStatus( + patchText: string, + managedHooksPath: string, + managedText: string | null, + configPath: string +): AgentHookInstallStatus { + if (managedText === null) { + return status(configPath, 'error', 'Could not read Orca managed hooks file') + } + // Why before the pointer check: a refused file carries no managed region, so the pointer + // path would report a bare `not_installed` and drop the one detail that says why. + if (isDshPatchFileUnappendable(patchText)) { + return status(configPath, 'error', FLOW_STYLE_DETAIL) + } + const pointer = readManagedDshHooksConfigPath(patchText) + if (pointer !== managedHooksPath) { + return status( + configPath, + 'not_installed', + pointer === undefined + ? null + : `The Orca patch block points at ${pointer}, not the Orca managed hooks file` + ) + } + const present = readManagedDshHookEvents( + parseJsonOrNull(managedText), + getDshManagedCommandMatcher() + ) + const missing = DSH_HOOK_EVENTS.filter((event) => !present.has(event)) + if (missing.length === 0) { + return status(configPath, 'installed', null, true) + } + // Why the split: nothing present is an uninstalled agent; some present is a broken install, + // and naming the gap is the only way a user can tell those apart. + return present.size === 0 + ? status(configPath, 'not_installed', null) + : status(configPath, 'partial', `Managed hook missing for events: ${missing.join(', ')}`, true) +} + +/** Installs Orca's status hooks into DSH. See `docs/reference/dsh-harness-integration.md` + * for the profile/patch-layer model and the env-scrub finding the aliases work around. */ +export class DshHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getDshManagedScriptPath(), getManagedScript()) + } + + getStatus(): AgentHookInstallStatus { + const configPath = getDshConfigPath() + const patchText = readTextOrAbsent(configPath) + if (patchText === null) { + return status(configPath, 'error', 'Could not read the DSH home patch file') + } + const managedHooksPath = getDshManagedHooksPath() + return buildStatus(patchText, managedHooksPath, readTextOrAbsent(managedHooksPath), configPath) + } + + install(): AgentHookInstallStatus { + const configPath = getDshConfigPath() + const patchText = readTextOrAbsent(configPath) + if (patchText === null) { + return status(configPath, 'error', 'Could not read the DSH home patch file') + } + const scriptPath = getDshManagedScriptPath() + const managedHooksPath = getDshManagedHooksPath() + // Write the script and the managed hooks file first so the patch layer never points at + // files that do not exist yet — a bridge that cannot read its config runs no hooks. + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson( + managedHooksPath, + { hooks: {} }, + { serialized: buildDshManagedHooksFile(getDshManagedCommand(scriptPath)) } + ) + const nextText = applyManagedDshPatch(patchText, managedHooksPath) + if (nextText === null) { + // Why refuse rather than edit: YAML forbids a block entry after a flow sequence, so + // appending here would leave DSH unable to parse the user's own patch layer either. + return status(configPath, 'error', FLOW_STYLE_DETAIL) + } + if (nextText !== patchText) { + writePatchText(configPath, nextText) + } + return this.getStatus() + } + + /** Install on an SSH execution host, where DSH's shell contract is always POSIX. */ + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remoteConfigPath = getDshRemoteConfigPath(remoteHome) + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/dsh-hook.sh` + const remoteManagedHooksPath = getDshRemoteManagedHooksPath(remoteHome) + try { + const body = (await readTextFileRemote(sftp, remoteConfigPath)) ?? '' + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeTextFileRemoteAtomic( + sftp, + remoteManagedHooksPath, + buildDshManagedHooksFile(getDshRemoteManagedCommand(remoteScriptPath)) + ) + const nextText = applyManagedDshPatch(body, remoteManagedHooksPath) + if (nextText === null) { + return status(remoteConfigPath, 'error', FLOW_STYLE_DETAIL) + } + await writeTextFileRemoteAtomic(sftp, remoteConfigPath, nextText) + return status(remoteConfigPath, 'installed', null, true) + } catch (err) { + return status(remoteConfigPath, 'error', err instanceof Error ? err.message : String(err)) + } + } + + remove(): AgentHookInstallStatus { + const configPath = getDshConfigPath() + const patchText = readTextOrAbsent(configPath) + if (patchText === null) { + return status(configPath, 'error', 'Could not read the DSH home patch file') + } + const { text: nextText, changed } = removeManagedDshPatch(patchText) + if (changed) { + writePatchText(configPath, nextText) + } + // Why force: the file is Orca's own and may already be gone; its absence is the goal. + rmSync(getDshManagedHooksPath(), { force: true }) + return this.getStatus() + } +} + +export const dshHookService = new DshHookService() diff --git a/src/main/dsh/hook-settings.ts b/src/main/dsh/hook-settings.ts new file mode 100644 index 00000000000..8ccbb8a9283 --- /dev/null +++ b/src/main/dsh/hook-settings.ts @@ -0,0 +1,110 @@ +import { homedir } from 'node:os' +import { join, posix as pathPosix } from 'node:path' +import { + buildManagedCommandHook, + createManagedCommandMatcher, + getSharedManagedScriptPath, + wrapPosixHookCommand, + wrapWindowsHookCommand, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { readManagedHookEventsFromJson } from '../agent-hooks/managed-hooks-json-events' + +const DSH_SCRIPT_BASE = 'dsh-hook' + +/** + * The events DeepSeek Harness's own Claude-Code hook bridge + * (`@deepseek-ai/dsh-hooks-claude-code`) can fire. This is a strict subset of Claude's: + * the bridge documents no `Notification`, no `PermissionRequest` and no `SessionEnd`, + * and registering an unsupported event name makes it register nothing for that event. + * `normalizeDshEvent` is written against exactly this list. + */ +export const DSH_HOOK_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'Stop' +] as const + +export const DSH_MANAGED_HOOKS_FILE_NAME = 'dsh-hooks.json' + +/** `$DSH_HOME`, matching the launcher's own `DSH_HOME ?? ~/.dsh` resolution. */ +export function getDshHome(): string { + return process.env.DSH_HOME?.trim() || join(homedir(), '.dsh') +} + +/** + * The home-level patch layer. + * + * Why here and not in a profile: DSH composes every profile as bundle patches, then the + * profile's own `cordis.patch.yml`, then this file. Installing one layer above every + * profile means a pane the user started themselves — any profile, including one Orca + * never launched — still reports status, and Orca never edits a profile the user owns. + */ +export function getDshConfigPath(): string { + return join(getDshHome(), 'cordis.patch.yml') +} + +export function getDshRemoteConfigPath(remoteHome: string): string { + // Why: a remote $DSH_HOME is unknown over SFTP; default matches the launcher's own resolution. + return pathPosix.join(remoteHome.replace(/\/$/, ''), '.dsh', 'cordis.patch.yml') +} + +export function getDshManagedScriptFileName(): string { + return process.platform === 'win32' ? `${DSH_SCRIPT_BASE}.cmd` : `${DSH_SCRIPT_BASE}.sh` +} + +export function getDshManagedScriptPath(): string { + return getSharedManagedScriptPath(getDshManagedScriptFileName()) +} + +export function getDshManagedHooksPath(): string { + return getSharedManagedScriptPath(DSH_MANAGED_HOOKS_FILE_NAME) +} + +export function getDshRemoteManagedHooksPath(remoteHome: string): string { + return pathPosix.join( + remoteHome.replace(/\/$/, ''), + '.orca', + 'agent-hooks', + DSH_MANAGED_HOOKS_FILE_NAME + ) +} + +export function getDshManagedCommand(scriptPath: string): string { + // Why: DSH runs hooks through `ctx.shell`, which the base profile binds to bash + // everywhere except Windows, where it binds to PowerShell — the same split these two + // wrappers already encode. + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) +} + +export function getDshRemoteManagedCommand(scriptPath: string): string { + return wrapPosixHookCommand(scriptPath) +} + +export function getDshManagedCommandMatcher(): (command: string | undefined) => boolean { + return createManagedCommandMatcher(getDshManagedScriptFileName()) +} + +/** + * The managed hooks file is Orca's outright: DSH has no user-owned `hooks.json` + * convention of its own, and the bridge reads whatever single path it is pointed at. So + * generate it wholesale rather than merging into someone's file. + */ +export function buildDshManagedHooksFile(command: string): string { + const hooks: Record = {} + for (const event of DSH_HOOK_EVENTS) { + hooks[event] = [{ hooks: [buildManagedCommandHook(command)] }] + } + return `${JSON.stringify({ hooks }, null, 2)}\n` +} + +export function readManagedDshHookEvents( + parsed: unknown, + isManagedCommand: (command: string | undefined) => boolean +): Set { + return readManagedHookEventsFromJson(parsed, DSH_HOOK_EVENTS, isManagedCommand) +} diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 0c2133f320e..907092515f5 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -23,6 +23,7 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], ['main/rate-limits/codex-fetcher.ts', 3], + ['main/rate-limits/zcode-usage-fetcher.ts', 1], ['main/runtime/push/push-gateway-client.ts', 1], ['main/runtime/relay/relay-http-client.ts', 2], ['main/runtime/relay/relay-region-catalog-fetch.ts', 1], diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts index 9cd715c3a73..59b7eea3cbc 100644 --- a/src/main/ipc/agent-trust.ts +++ b/src/main/ipc/agent-trust.ts @@ -1,3 +1,4 @@ +import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust' import { ipcMain } from 'electron' import { type AgentTrustPreset, @@ -42,6 +43,8 @@ export function registerAgentTrustHandlers(): void { }), { 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') { diff --git a/src/main/ipc/freebuff-detection.test.ts b/src/main/ipc/freebuff-detection.test.ts new file mode 100644 index 00000000000..6c74e6409b5 --- /dev/null +++ b/src/main/ipc/freebuff-detection.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from './tui-agent-detection-commands' + +describe('Freebuff detection', () => { + it.each(['darwin', 'linux', 'win32', 'wsl'] as const)( + 'detects Freebuff independently of Codebuff on %s', + (runtime) => { + expect( + getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime) + ).toContain('freebuff') + expect( + resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + new Set(['freebuff']), + runtime + ) + ).toEqual(['freebuff']) + expect( + resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + new Set(['codebuff']), + runtime + ) + ).toEqual(['codebuff']) + expect( + resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + new Set(['freebuff', 'codebuff']), + runtime + ) + ).toEqual(expect.arrayContaining(['freebuff', 'codebuff'])) + } + ) +}) diff --git a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts index 128f163c5b3..5ae2adb49d7 100644 --- a/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts +++ b/src/main/ipc/pty-daemon-ssh-lease-lifecycle.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { openCodeClearPtyMock, piClearPtyMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' import { SSH_PTY_IDENTITY_MISMATCH_ERROR, SSH_SESSION_EXPIRED_ERROR @@ -359,7 +360,8 @@ describe('registerPtyHandlers', () => { const exitListeners = new Set<(payload: { id: string; code: number }) => void>() const runtime = { setPtyController: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } setLocalPtyProvider({ spawn: vi.fn(), @@ -393,15 +395,14 @@ describe('registerPtyHandlers', () => { handlers.clear() registerPtyHandlers(mainWindow as never, runtime as never) const controller = runtime.setPtyController.mock.calls[0]?.[0] as { - markReversibleStops: (ptyIds: readonly string[]) => () => void stopAndWait: (ptyId: string) => Promise } - const release = controller.markReversibleStops(['local-pty']) + const settleStop = runtime.intentionalPtyStops.mark('local-pty', 'reversible', null) const stopPromise = controller.stopAndWait('local-pty') await vi.advanceTimersByTimeAsync(1_200) await expect(stopPromise).resolves.toBe(true) - release() + settleStop(true) expect( mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit') diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index a1ced7904f8..50558a0f870 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -3,6 +3,7 @@ import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harne import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, setLocalPtyProvider } from './pty' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -161,7 +162,8 @@ function installRestartHarness( seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), - onPtyData: vi.fn() + onPtyData: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } return { providerSpawn, shutdown, store, runtime, control } } diff --git a/src/main/ipc/pty-reset-input-modes.test.ts b/src/main/ipc/pty-reset-input-modes.test.ts new file mode 100644 index 00000000000..55fa628078d --- /dev/null +++ b/src/main/ipc/pty-reset-input-modes.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import { onMock } from './pty-ipc-mock-registry' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('Reset Terminal main-side entry points', () => { + const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() + + function setup() { + const resetInputModes = vi.fn(async () => {}) + installDaemonTestProvider({ resetInputModes }) + let controller: { resetInputModes: (ptyId: string) => Promise } | undefined + const runtime = { + setPtyController: vi.fn((next) => { + controller = next + }), + resetHeadlessTerminalInputModes: vi.fn(async () => {}) + } + handlers.clear() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration reads only these members. + registerPtyHandlers(mainWindow as never, runtime as never) + return { resetInputModes, runtime, controller: controller! } + } + + it('grounds the provider and the headless model from the pane IPC', () => { + const { resetInputModes, runtime } = setup() + const listener = onMock.mock.calls.findLast( + (entry: unknown[]) => entry[0] === 'pty:resetInputModes' + )?.[1] + if (typeof listener !== 'function') { + throw new Error('missing pty:resetInputModes listener') + } + + listener(null, { id: 'pty-1' }) + + expect(resetInputModes).toHaveBeenCalledWith('pty-1') + expect(runtime.resetHeadlessTerminalInputModes).toHaveBeenCalledWith('pty-1') + // The pane grounded itself before sending; echoing back would ground it twice. + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:resetInputModes:request', + expect.anything() + ) + }) + + it("grounds the host window's pane and the provider for a runtime-initiated reset", async () => { + const { resetInputModes, controller } = setup() + + await controller.resetInputModes('pty-1') + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:resetInputModes:request', { + ptyId: 'pty-1' + }) + expect(resetInputModes).toHaveBeenCalledWith('pty-1') + }) + + it('swallows an older host rejecting the request', async () => { + const { resetInputModes, controller } = setup() + resetInputModes.mockRejectedValueOnce(new Error('Unknown request type: resetInputModes')) + + await expect(controller.resetInputModes('pty-1')).resolves.toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty-ssh-undelivered-kill.test.ts b/src/main/ipc/pty-ssh-undelivered-kill.test.ts index bf231fdc235..2d92ffa1859 100644 --- a/src/main/ipc/pty-ssh-undelivered-kill.test.ts +++ b/src/main/ipc/pty-ssh-undelivered-kill.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' import { SSH_SESSION_EXPIRED_ERROR } from '../providers/ssh-pty-errors' import { registerPtyHandlers, @@ -79,7 +80,8 @@ function installController(handlers: Map) { setPtyController: vi.fn(), markPtyStopRequested: vi.fn(), markPtyLivenessUnverifiable: vi.fn(), - onPtyExit: vi.fn() + onPtyExit: vi.fn(), + intentionalPtyStops: new TerminalIntentionalStops() } handlers.clear() return { runtime } @@ -91,7 +93,6 @@ describe('undelivered SSH stops', () => { function install(store: ReturnType): { kill: (ptyId: string) => boolean stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise - markReversibleStops: (ptyIds: readonly string[]) => () => void recordUnconfirmedStop: (ptyId: string) => boolean runtime: ReturnType['runtime'] } { @@ -108,13 +109,11 @@ describe('undelivered SSH stops', () => { const controller = runtime.setPtyController.mock.calls[0]?.[0] as { kill: (ptyId: string) => boolean stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise - markReversibleStops: (ptyIds: readonly string[]) => () => void recordUnconfirmedStop: (ptyId: string) => boolean } return { kill: controller.kill, stopAndWait: controller.stopAndWait, - markReversibleStops: controller.markReversibleStops, recordUnconfirmedStop: controller.recordUnconfirmedStop, runtime } @@ -302,15 +301,41 @@ describe('undelivered SSH stops', () => { ) setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f') - const { kill, markReversibleStops } = install(store) - const release = markReversibleStops([SCOPED_PTY_ID]) + const { kill, runtime } = install(store) + const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) try { kill(SCOPED_PTY_ID) await new Promise((resolve) => setTimeout(resolve, 0)) expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() } finally { - release() + settleStop(false) + unregisterSshPtyProvider('ssh-1') + deletePtyOwnership(SCOPED_PTY_ID) + } + }) + + it('records nothing while a reversible stop owns the PTY and a restart stop joins it', async () => { + const store = createKillStore() + registerSshPtyProvider( + 'ssh-1', + sshProviderStub(async () => { + throw new Error('socket closed') + }) + ) + setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') + restorePtyIncarnation(SCOPED_PTY_ID, 'inc-f') + const { kill, runtime } = install(store) + const settleSleep = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) + const settleRestart = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'replaced', null) + + try { + kill(SCOPED_PTY_ID) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() + } finally { + settleRestart(false) + settleSleep(false) unregisterSshPtyProvider('ssh-1') deletePtyOwnership(SCOPED_PTY_ID) } @@ -331,8 +356,8 @@ describe('undelivered SSH stops', () => { } }) - // No incarnation means no fence, and an unfenced order can only be discarded or guessed at. - it('records nothing when the PTY incarnation was never learned', async () => { + // A legacy id with no incarnation has no fence, and an unfenced order can only be guessed at. + it('records nothing for a legacy id whose PTY incarnation was never learned', async () => { const store = createKillStore() registerSshPtyProvider( 'ssh-1', @@ -353,6 +378,44 @@ describe('undelivered SSH stops', () => { } }) + // A `pty2:` id is epoch-scoped, so it names one process even when the incarnation was never + // learned: an offline close across a relaunch still owes the kill. + it('records the stop for an epoch-scoped id whose incarnation was never learned', () => { + const store = createKillStore() + const ptyId = 'ssh:ssh-1@@pty2:epoch-a:4' + setPtyOwnership(ptyId, 'ssh-1') + const { kill } = install(store) + + try { + expect(kill(ptyId)).toBe(false) + expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith('ssh-1', 'pty2:epoch-a:4', { + requestedAt: expect.any(Number), + attempts: 0 + }) + } finally { + deletePtyOwnership(ptyId) + } + }) + + // One recorder: the explicit-close receipt promises the retry for an epoch-scoped id too. + it("records an explicit close's unconfirmed stop for an epoch-scoped id with no incarnation", () => { + const store = createKillStore() + const ptyId = 'ssh:ssh-1@@pty2:epoch-b:5' + setPtyOwnership(ptyId, 'ssh-1') + const { recordUnconfirmedStop } = install(store) + + try { + expect(recordUnconfirmedStop(ptyId)).toBe(true) + expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledTimes(1) + expect(store.recordSshRemotePtyKillIntent).toHaveBeenCalledWith('ssh-1', 'pty2:epoch-b:5', { + requestedAt: expect.any(Number), + attempts: 0 + }) + } finally { + deletePtyOwnership(ptyId) + } + }) + // An explicit close records its order when the stop goes unconfirmed, before the follow-up kill, // so its receipt can promise the reconnect retry from the record itself. it("records an explicit close's unconfirmed stop and says so", () => { @@ -379,15 +442,15 @@ describe('undelivered SSH stops', () => { restorePtyIncarnation('local-pty', 'inc-h') setPtyOwnership(SCOPED_PTY_ID, 'ssh-1') restorePtyIncarnation(SCOPED_PTY_ID, 'inc-i') - const { recordUnconfirmedStop, markReversibleStops } = install(store) - const release = markReversibleStops([SCOPED_PTY_ID]) + const { recordUnconfirmedStop, runtime } = install(store) + const settleStop = runtime.intentionalPtyStops.mark(SCOPED_PTY_ID, 'reversible', null) try { expect(recordUnconfirmedStop('local-pty')).toBe(false) expect(recordUnconfirmedStop(SCOPED_PTY_ID)).toBe(false) expect(store.recordSshRemotePtyKillIntent).not.toHaveBeenCalled() } finally { - release() + settleStop(false) deletePtyOwnership('local-pty') deletePtyOwnership(SCOPED_PTY_ID) } diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts index a7575f7c2e6..ad144b4d587 100644 --- a/src/main/ipc/pty/delivery/exit.ts +++ b/src/main/ipc/pty/delivery/exit.ts @@ -7,57 +7,8 @@ import { allocatePtyLifecycleSequence } from '../host-env/types' import { makePtyDataPayload, sendPtyDataToRenderer } from './payload' import { getRendererInFlightCharsForPty } from './accounting' import { clearFlushTimerIfIdle } from './flush' -import { ptyIncarnationById } from '../provider/ownership-state' import type { PtyIpcSession } from '../session' -export type ReplacedPtyStop = { - incarnationId: string | undefined - expiryTimer?: NodeJS.Timeout -} - -/** Labels the exit of a PTY that main stops so a new process can take its pane. Settle with - * whether the stop succeeded; a failed stop leaves no label behind. */ -export function markReplacedPtyStop( - session: PtyIpcSession, - id: string -): (stopped: boolean) => void { - clearTimeout(session.replacedPtyStopsById.get(id)?.expiryTimer) - const mark: ReplacedPtyStop = { incarnationId: ptyIncarnationById.get(id) } - session.replacedPtyStopsById.set(id, mark) - return (stopped) => { - if (session.replacedPtyStopsById.get(id) !== mark) { - return - } - if (!stopped) { - session.replacedPtyStopsById.delete(id) - return - } - // Why a window: an SSH exit can reach the renderer after the stop settles; bound it like a synthetic kill. - mark.expiryTimer = setTimeout(() => { - if (session.replacedPtyStopsById.get(id) === mark) { - session.replacedPtyStopsById.delete(id) - } - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - mark.expiryTimer.unref?.() - } -} - -function consumeReplacedPtyStop( - session: PtyIpcSession, - payload: { id: string; incarnationId?: string } -): boolean { - const mark = session.replacedPtyStopsById.get(payload.id) - if ( - !mark || - (mark.incarnationId && payload.incarnationId && mark.incarnationId !== payload.incarnationId) - ) { - return false - } - clearTimeout(mark.expiryTimer) - session.replacedPtyStopsById.delete(payload.id) - return true -} - export function rememberSyntheticKillExit( session: PtyIpcSession, id: string, @@ -202,12 +153,12 @@ export function finalizePtyExitForRenderer( session.schedulePendingDataAfterCreditReport(true) } } + const intentionalStops = + session.runtime?.intentionalPtyStops?.claimExit(payload.id, payload.incarnationId) ?? [] session.mainWindow.webContents.send('pty:exit', { ...payload, - ...(session.reversibleStopOwnersByPtyId.has(payload.id) - ? { preserveRendererBinding: true } - : {}), - ...(consumeReplacedPtyStop(session, payload) ? { replacedByRestart: true } : {}) + ...(intentionalStops.includes('reversible') ? { preserveRendererBinding: true } : {}), + ...(intentionalStops.includes('replaced') ? { replacedByRestart: true } : {}) }) } diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index 8f73e72aa59..10a0ffa476e 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -1,10 +1,11 @@ import { getPtyIpc } from '../../pty-host-bindings' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { TerminalIntentionalStopKind } from '../../../runtime/terminal-intentional-stops' import type { IPtyProvider } from '../../../providers/types' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' -import { ptyOwnership } from '../provider/ownership-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { getProviderForPty, sshProviders, tryGetProviderForPty } from '../provider/registry' import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness' import { recordUndeliveredSshPtyKill } from '../runtime/undelivered-ssh-kill' @@ -31,27 +32,49 @@ export function installPtyKillIpcHandler(deps: PtyKillIpcDeps): void { ) } -/** Stops a pane's PTY for the spawn replacing it. `markReplaced` labels the exit so the renderer - * reads it as a handoff, not the pane dying; a failed stop removes the label with nothing sent. */ -export async function stopReplacedPanePty( - deps: PtyKillIpcDeps, - id: string, - markReplaced: (id: string) => (stopped: boolean) => void -): Promise { - const settle = markReplaced(id) - try { - await stopRendererOwnedPty(deps, { id }) - } catch (err) { - settle(false) - throw err - } - settle(true) +/** Stops a pane's PTY for the spawn replacing it. The stop register labels the exit so the + * renderer reads it as a handoff, not the pane dying; a failed stop leaves no label. */ +export async function stopReplacedPanePty(deps: PtyKillIpcDeps, id: string): Promise { + await stopRendererOwnedPtyAs(deps, { id }, 'replaced') } /** Stops a renderer-owned PTY and settles only once its shutdown has been observed or synthesized. */ export async function stopRendererOwnedPty( deps: PtyKillIpcDeps, args: { id: string; keepHistory?: boolean } +): Promise { + // Why: only hibernation passes keepHistory, and its exit must keep the pane's wake binding. + await stopRendererOwnedPtyAs(deps, args, args?.keepHistory === true ? 'reversible' : null) +} + +async function stopRendererOwnedPtyAs( + deps: PtyKillIpcDeps, + args: { id: string; keepHistory?: boolean }, + intentionalStop: TerminalIntentionalStopKind | null +): Promise { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. + throw new Error('Invalid PTY provider id') + } + const settleStop = intentionalStop + ? deps.runtime?.intentionalPtyStops?.mark( + args.id, + intentionalStop, + ptyIncarnationById.get(args.id) ?? null + ) + : undefined + let stopped = false + try { + await stopRendererOwnedPtyProcess(deps, args) + stopped = true + } finally { + settleStop?.(stopped) + } +} + +async function stopRendererOwnedPtyProcess( + deps: PtyKillIpcDeps, + args: { id: string; keepHistory?: boolean } ): Promise { const { store, @@ -61,10 +84,6 @@ export async function stopRendererOwnedPty( rememberSyntheticKillExit, sendPtyExitToRenderer } = deps - if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { - // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. - throw new Error('Invalid PTY provider id') - } runtime?.markPtyStopRequested?.(args.id) const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts index c9f3e92c620..62e74738b72 100644 --- a/src/main/ipc/pty/ipc/resize-visibility.ts +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -322,4 +322,13 @@ export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { .catch(() => {}) runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) }) + + ipcMain.removeAllListeners('pty:resetInputModes') + ipcMain.on('pty:resetInputModes', (_event, args: { id: string }) => { + // Why: an older daemon or relay rejects the request; its model keeps the modes until reattach. + tryGetProviderForPty(args.id) + ?.resetInputModes(args.id) + .catch(() => {}) + runtime?.resetHeadlessTerminalInputModes(args.id).catch(() => {}) + }) } diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 5d9ad086920..3740e1489a0 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -83,6 +83,8 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

) { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + const runtime = { + terminalRunFacts: facts, + intentionalPtyStops: stops, + // Why the real method: the case under test is what the runtime does with each commit. + noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } + const ports = { + runtime, + store: { persistPtyBinding }, + options: {}, + sendPtySpawnedToRenderer: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above, and the store only for its binding save. + const deps = ports as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1', + cols: 120, + rows: 40 + }) + ctx.validatedLeafId = 'leaf-1' + ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } + ctx.result = { id: PTY_ID, incarnationId: INCARNATION_ID } + const outcome = await commitPtyIpcSpawn(ctx).then( + () => 'committed', + () => 'discarded' + ) + return { outcome, facts, stops } +} + +describe('renderer spawn commit: run facts', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + }) + + it('records a committed spawn and lets it supersede a landed stop no exit pinned', async () => { + const { outcome, facts, stops } = await commit(vi.fn().mockResolvedValue(true)) + + expect(outcome).toBe('committed') + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(true) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('records nothing for a spawn discarded because its binding save failed', async () => { + const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + const { outcome, facts, stops } = await commit(persistPtyBinding) + + expect(outcome).toBe('discarded') + expect(persistPtyBinding).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts index ceeb5127b7d..4afd2b008f1 100644 --- a/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts +++ b/src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts @@ -68,7 +68,7 @@ describe('chunked pty write yield', () => { }) as typeof setImmediate) const outcome = await Promise.race([ - createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }), + createWriteInput()({ inputKind: 'driving', id: PTY_ID, data: THREE_CHUNK_INPUT }), afterImmediateTurns(50) ]) @@ -88,6 +88,7 @@ describe('chunked pty write yield', () => { const immediate = vi.spyOn(globalThis, 'setImmediate') const outcome = createWriteInput()({ + inputKind: 'driving', id: PTY_ID, data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) }) diff --git a/src/main/ipc/pty/ipc/write-input-user-input.test.ts b/src/main/ipc/pty/ipc/write-input-user-input.test.ts new file mode 100644 index 00000000000..d4f20f00a68 --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input-user-input.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptyOwnership } from '../provider/ownership-state' +import { createPtyWriteInput } from './write-input' + +const PTY_ID = 'pty-user-input' + +const { provider } = vi.hoisted(() => ({ + provider: { write: vi.fn(), hasPty: vi.fn(() => true) } +})) + +vi.mock('../provider/registry', () => ({ + tryGetProviderForPty: (id: string) => (id === PTY_ID ? provider : undefined) +})) + +function createWriteInput(facts: TerminalRunFactsRegister) { + const runtime = { getDriver: () => ({ kind: 'desktop' }), terminalRunFacts: facts } + const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: write input reads only getDriver and terminalRunFacts from the runtime, and isDestroyed/webContents from the window. + return createPtyWriteInput({ mainWindow: mainWindow as never, runtime: runtime as never }) +} + +beforeEach(() => { + ptyOwnership.set(PTY_ID, null) + provider.write.mockReset() +}) + +afterEach(() => { + ptyOwnership.delete(PTY_ID) +}) + +describe('renderer PTY writes: input kind', () => { + it.each(['writePtyInput', 'writePtyInputAccepted'] as const)( + '%s records driving input before the provider write', + async (writer) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' }) + const recordedAtWrite: (number | null)[] = [] + provider.write.mockImplementation(() => { + recordedAtWrite.push(facts.read(PTY_ID, 'inc-1').firstUserInputAt) + }) + + await createWriteInput(facts)[writer]({ id: PTY_ID, data: 'exit\r', inputKind: 'driving' }) + + expect(recordedAtWrite).toEqual([expect.any(Number)]) + } + ) + + it.each([ + ['a launch write', 'launch', 'echo startup\r'], + ['a query reply', 'query-reply', '\x1b[3;4R'], + ['a driving write that is only a reply', 'driving', '\x1b[3;4R'], + ['a driving write that is only focus reports', 'driving', '\x1b[I\x1b[O'] + ] as const)('records nothing for %s', async (_label, inputKind, data) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: PTY_ID, incarnationId: 'inc-1' }) + + await createWriteInput(facts).writePtyInput({ id: PTY_ID, data, inputKind }) + + expect(provider.write).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, 'inc-1').firstUserInputAt).toBeNull() + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index 77b27ef16f0..2f8ea72cd9b 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -9,6 +9,7 @@ import { } from '../../../../shared/terminal-input' import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import { interactiveOutputCharsByPty, lastInputAtByPty } from '../delivery/visibility-state' export function isMainWindowPtyIpcEvent( @@ -25,7 +26,7 @@ export function isMainWindowPtyIpcEvent( ) } -export type PtyWritePayload = { id: string; data: string } +export type PtyWritePayload = { id: string; data: string; inputKind: TerminalInputKind } export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } export function createPtyWriteInput(deps: { @@ -148,6 +149,12 @@ export function createPtyWriteInput(deps: { const isPtyWriteEventFromMainWindow = (event: IpcMainEvent | IpcMainInvokeEvent): boolean => isMainWindowPtyIpcEvent(event, mainWindow) + const noteRendererPtyInput = (args: PtyWritePayload): void => { + lastInputAtByPty.set(args.id, performance.now()) + interactiveOutputCharsByPty.set(args.id, 0) + runtime?.terminalRunFacts?.recordInput(args.id, args.inputKind, args.data) + } + const writePtyInput = (args: PtyWritePayload): boolean | Promise => { // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. if (runtime?.getDriver(args.id).kind === 'mobile') { @@ -158,9 +165,7 @@ export function createPtyWriteInput(deps: { return false } try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + noteRendererPtyInput(args) return writePtyProviderInput(provider, args.id, args.data) } catch { return false @@ -180,9 +185,7 @@ export function createPtyWriteInput(deps: { return false } try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) + noteRendererPtyInput(args) return writePtyProviderInput(provider, args.id, args.data) } catch { return false diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index aaace9fac37..5c7c897474a 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -17,7 +17,6 @@ import { stopReplacedPanePty, type PtyKillIpcDeps } from './ipc/renderer-kill' -import { markReplacedPtyStop } from './delivery/exit' import { installPtyWriteIpcHandlers } from './ipc/write' import { installPtySpawnIpcHandler } from './ipc/spawn' import { installPtyRuntimeController } from './runtime/controller' @@ -237,7 +236,6 @@ export function registerPtyHandlers( options, trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, retiredRejectedPtyIds: session.retiredRejectedPtyIds, - reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId, mainWindow, transitionSpawnHiddenRendererPtyDeliveryState: session.transitionSpawnHiddenRendererPtyDeliveryState, @@ -275,8 +273,7 @@ export function registerPtyHandlers( trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, syncPtyBackgroundedDelivery: session.syncPtyBackgroundedDelivery, - stopReplacedPty: (id) => - stopReplacedPanePty(killDeps, id, (ptyId) => markReplacedPtyStop(session, ptyId)) + stopReplacedPty: (id) => stopReplacedPanePty(killDeps, id) }) installPtyWriteIpcHandlers({ mainWindow, runtime }) installPtyResizeVisibilityIpc(session) diff --git a/src/main/ipc/pty/register-without-renderer.test.ts b/src/main/ipc/pty/register-without-renderer.test.ts index 513d969f110..a5d19f020fc 100644 --- a/src/main/ipc/pty/register-without-renderer.test.ts +++ b/src/main/ipc/pty/register-without-renderer.test.ts @@ -128,8 +128,8 @@ describe('PTY registration without renderer delivery', () => { throw new Error('missing runtime PTY controller') } - expect(controller.write('daemon-pty', 'local input')).toBe(true) - expect(controller.write(remoteId, 'remote input')).toBe(true) + expect(controller.write('daemon-pty', 'local input', 'driving')).toBe(true) + expect(controller.write(remoteId, 'remote input', 'driving')).toBe(true) await controller.clearBuffer?.('daemon-pty') await controller.clearBuffer?.(remoteId) await expect(controller.attach?.('daemon-pty')).resolves.toBe(true) @@ -147,7 +147,7 @@ describe('PTY registration without renderer delivery', () => { expect(attach).toHaveBeenCalledExactlyOnceWith('daemon-pty') unregisterSshPtyProvider('ssh-a') - expect(controller.write(remoteId, 'disconnected input')).toBe(false) + expect(controller.write(remoteId, 'disconnected input', 'driving')).toBe(false) await expect(controller.probePtyLiveness?.(remoteId)).resolves.toBeNull() expect(local.write).toHaveBeenCalledTimes(1) }) diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 8f20245505c..e1bfd5d7f10 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -82,7 +82,6 @@ export type PtyRuntimeControllerDeps = { } trustedTerminalHandleEnv: Set retiredRejectedPtyIds: Map - reversibleStopOwnersByPtyId: Map mainWindow?: PtyRendererDelivery transitionSpawnHiddenRendererPtyDeliveryState?: (id: string, hidden: boolean) => void syncPtyBackgroundedDelivery?: (id: string, caller: string) => void diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 06ce1891b69..4013e06e37f 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -4,13 +4,13 @@ import type { PtyRuntimeControllerDeps } from './controller-deps' import { spawnPtyFromRuntimeController } from './spawn' import { killPtyFromRuntimeController, - markReversibleStopsFromRuntimeController, retireRejectedPtyFromRuntimeController, stopAndWaitPtyFromRuntimeController } from './kill' import { attachPtyFromRuntimeController, clearBufferFromRuntimeController, + resetInputModesFromRuntimeController, confirmForegroundProcessFromRuntimeController, confirmShellForegroundFromRuntimeController, getCwdFromRuntimeController, @@ -45,9 +45,9 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi }, adoptStablePane, spawn: async (args) => spawnPtyFromRuntimeController(deps, args), - write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), - writeWithSettlement: (ptyId, data) => - writePtyFromRuntimeController(ptyId, data, { waitForSettlement: true }), + write: (ptyId, data, inputKind) => writePtyFromRuntimeController(deps, ptyId, data, inputKind), + writeWithSettlement: (ptyId, data, inputKind) => + writePtyFromRuntimeController(deps, ptyId, data, inputKind, { waitForSettlement: true }), probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), // Why: subscriber-driven ingestion for daemon sessions no renderer pane // ever attached. Local daemon sessions only — SSH panes have their own @@ -57,13 +57,12 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), retireRejectedPty: (ptyId, stopConfirmed) => retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed), - markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds), stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts), recordUnconfirmedStop: (ptyId) => recordUnconfirmedExplicitSshStop({ store: deps.store, ptyId, - reversible: deps.reversibleStopOwnersByPtyId.has(ptyId) + reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false }), getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), inspectProcess: (ptyId, options) => inspectProcessFromRuntimeController(ptyId, options), @@ -72,6 +71,7 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), + resetInputModes: (ptyId) => resetInputModesFromRuntimeController(deps, ptyId), hasPty: (ptyId) => hasPtyFromRuntimeController(deps, ptyId), listProcesses: (connectionId, opts) => listProcessesFromRuntimeController(deps, connectionId, opts), diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index 98bdc1e97ae..edbd12b0b18 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -19,8 +19,7 @@ export function killPtyFromRuntimeController( rememberSyntheticKillExit, sendPtyExitToRenderer, finishPtyShutdown, - retiredRejectedPtyIds, - reversibleStopOwnersByPtyId + retiredRejectedPtyIds } = deps runtime?.markPtyStopRequested?.(ptyId) let connectionId: string | null | undefined = ptyOwnership.get(ptyId) @@ -31,7 +30,7 @@ export function killPtyFromRuntimeController( store, ptyId, connectionId, - reversible: reversibleStopOwnersByPtyId.has(ptyId), + reversible: runtime?.intentionalPtyStops?.isReversibleStopInFlight(ptyId) ?? false, incarnationId }) } @@ -184,31 +183,6 @@ export function retireRejectedPtyFromRuntimeController( }) } -export function markReversibleStopsFromRuntimeController( - deps: PtyRuntimeControllerDeps, - ptyIds: readonly string[] -): () => void { - const { reversibleStopOwnersByPtyId } = deps - for (const ptyId of ptyIds) { - reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const ptyId of ptyIds) { - const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 - if (owners > 0) { - reversibleStopOwnersByPtyId.set(ptyId, owners) - } else { - reversibleStopOwnersByPtyId.delete(ptyId) - } - } - } -} - /** * Deliberately records no undelivered-stop intent, unlike `killPtyFromRuntimeController`. * diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index f03892a6e31..c555c0b3102 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -12,16 +12,28 @@ import { writeUnverifiable, type WriteSettlement } from '../../../../shared/pty-write-settlement' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' + +type RuntimeWriteDeps = Pick -export function writePtyFromRuntimeController(ptyId: string, data: string): boolean export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, ptyId: string, data: string, + inputKind: TerminalInputKind +): boolean +export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, + ptyId: string, + data: string, + inputKind: TerminalInputKind, options: { waitForSettlement: true } ): WriteSettlement | Promise export function writePtyFromRuntimeController( + deps: RuntimeWriteDeps, ptyId: string, data: string, + inputKind: TerminalInputKind, options?: { waitForSettlement: true } ): boolean | WriteSettlement | Promise { let provider: IPtyProvider @@ -36,6 +48,7 @@ export function writePtyFromRuntimeController( if (!provider.writeWithSettlement) { return writeRefused('provider_cannot_settle') } + deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data) try { return provider.writeWithSettlement(ptyId, data) } catch { @@ -43,6 +56,7 @@ export function writePtyFromRuntimeController( return writeUnverifiable('provider_threw_after_handoff', true) } } + deps.runtime?.terminalRunFacts?.recordInput(ptyId, inputKind, data) try { return provider.write(ptyId, data) !== false } catch { @@ -179,6 +193,21 @@ export async function clearBufferFromRuntimeController( } } +export async function resetInputModesFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + // Why: a remote client's reset must also ground this host window's view of the pane. + if (deps.mainWindow && !deps.mainWindow.isDestroyed()) { + deps.mainWindow.webContents.send('pty:resetInputModes:request', { ptyId }) + } + try { + await getProviderForPty(ptyId).resetInputModes(ptyId) + } catch { + /* best effort: an older daemon or relay rejects the request */ + } +} + const settledLocalPtyProviderStartups = new WeakSet>() const watchedLocalPtyProviderStartups = new WeakSet>() diff --git a/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts new file mode 100644 index 00000000000..5701b9f82c9 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit-run-facts.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeWithRuntimeId } from '../../../runtime/orca-runtime-runtime-id' +import { TerminalIntentionalStops } from '../../../runtime/terminal-intentional-stops' +import { TerminalRunFactsRegister } from '../../../runtime/terminal-run-facts' +import { ptySizes } from '../delivery/visibility-state' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { commitRuntimePtySpawn } from './spawn-commit' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +const PTY_ID = 'orca-pty-run-facts' +const INCARNATION_ID = 'inc-run-facts' + +const ADOPTED = { + disposition: 'adopted', + owner: { + claim: { kind: 'terminal' }, + generation: 'g1', + phase: 'live', + ptyId: PTY_ID, + surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1', terminalHandle: 'h1' } + } +} + +async function commit( + result: Record, + facts = new TerminalRunFactsRegister(), + intentionalPtyStops = new TerminalIntentionalStops(), + prepare?: (ctx: ReturnType) => void +) { + const runtime = { + terminalRunFacts: facts, + intentionalPtyStops, + // Why the real method: the case under test is what the runtime does with each commit. + noteTerminalSpawnCommit: OrcaRuntimeWithRuntimeId.prototype.noteTerminalSpawnCommit, + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } + const ports = { runtime, store: undefined, options: {}, sendPtySpawnedToRenderer: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit reads only the ports above; store-less deps skip every persistence branch. + const deps = ports as unknown as PtyRuntimeControllerDeps + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a worktree-less spawn needs only its grid. + const args = { cols: 120, rows: 40 } as unknown as RuntimePtySpawnArgs + const ctx = createRuntimePtySpawnState(deps, args) + const spawned = { id: PTY_ID, incarnationId: INCARNATION_ID, ...result } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each case sets the spawn-result fields the commit reads. + ctx.result = spawned as unknown as typeof ctx.result + prepare?.(ctx) + await commitRuntimePtySpawn(ctx) + return facts.read(PTY_ID, INCARNATION_ID) +} + +describe('runtime spawn commit: run facts', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + }) + + it('records a new process as a fresh spawn', async () => { + expect((await commit({})).freshSpawn).toBe(true) + }) + + it('never reads an SSH adoption that omits isReattach as fresh', async () => { + expect((await commit({ agentSessionEnsure: ADOPTED })).freshSpawn).toBe(false) + }) + + it('never reads a cold restore as fresh', async () => { + const coldRestore = { scrollback: 'prior output', cwd: '/tmp' } + + expect((await commit({ coldRestore })).freshSpawn).toBe(false) + }) + + it('keeps a process run facts when the same incarnation commits again', async () => { + const facts = new TerminalRunFactsRegister() + await commit({}, facts) + facts.recordInput(PTY_ID, 'driving', 'ls\r', 100) + + expect(await commit({ isReattach: true }, facts)).toEqual({ + freshSpawn: true, + firstUserInputAt: 100 + }) + }) + + it.each([ + { spawn: 'new process', result: {} }, + { spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } } + ])('lets a committed $spawn supersede a landed stop that no exit pinned', async ({ result }) => { + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + + await commit(result, new TerminalRunFactsRegister(), stops) + + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('records nothing for a spawn discarded because its binding save failed', async () => { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + const persistPtyBinding = vi.fn().mockRejectedValue(new Error('disk full')) + + await expect( + commit({}, facts, stops, (ctx) => { + ctx.provider = { ...ctx.provider, shutdown: vi.fn().mockResolvedValue(undefined) } + ctx.hostSessionBinding = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the commit calls only persistPtyBinding on this store. + store: { persistPtyBinding } as unknown as NonNullable< + typeof ctx.hostSessionBinding + >['store'], + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'leaf-1' + } + }) + ).rejects.toThrow() + + expect(persistPtyBinding).toHaveBeenCalledOnce() + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) + + it.each([ + { spawn: 'new process', result: {} }, + { spawn: 'adoption', result: { agentSessionEnsure: ADOPTED } } + ])('records nothing for a $spawn that exited during start', async ({ result }) => { + const facts = new TerminalRunFactsRegister() + const stops = new TerminalIntentionalStops() + stops.mark(PTY_ID, 'reversible', null)(true) + + await expect( + commit(result, facts, stops, (ctx) => { + ctx.args.worktreeId = 'wt-1' + ctx.deps.runtime!.registerPty = vi.fn(() => { + throw new Error('agent_session_exited_during_start') + }) + }) + ).rejects.toThrow('agent_session_exited_during_start') + + expect(facts.read(PTY_ID, INCARNATION_ID).freshSpawn).toBe(false) + expect(stops.claimExit(PTY_ID, INCARNATION_ID)).toEqual(['reversible']) + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 856039e7d5a..95994e0effc 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -92,6 +92,11 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (rejectedRegistration) { await rejectedRegistration } + // Why here: an adoption returns before the commit site below. + ctx.deps.runtime?.noteTerminalSpawnCommit?.( + ctx.result, + ctx.hostSessionBinding?.expectedSourceBinding + ) ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) if (ctx.result.incarnationId) { @@ -187,6 +192,12 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) } + // Why after registration: a spawn discarded for a failed save or rejected for exiting during + // start must not record facts or end a stop. + ctx.deps.runtime?.noteTerminalSpawnCommit?.( + ctx.result, + ctx.hostSessionBinding?.expectedSourceBinding + ) if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) } diff --git a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts index 46f16081efa..a299327fc3f 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-requested-shell.test.ts @@ -43,7 +43,6 @@ function makeDeps(): PtyRuntimeControllerDeps { finishPtyShutdown, trustedTerminalHandleEnv: new Set(), retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `operations.ts` (write/clearBuffer) reads `mainWindow`; the spawn preflight and option build never touch it, and a real BrowserWindow cannot exist in vitest. mainWindow: {} as BrowserWindow } diff --git a/src/main/ipc/pty/runtime/undelivered-ssh-kill.ts b/src/main/ipc/pty/runtime/undelivered-ssh-kill.ts index 00d23478eaa..45b80ea1f9e 100644 --- a/src/main/ipc/pty/runtime/undelivered-ssh-kill.ts +++ b/src/main/ipc/pty/runtime/undelivered-ssh-kill.ts @@ -2,6 +2,7 @@ import type { Store } from '../../../persistence' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' import { getRelayPtyId } from '../provider/registry' +import { isEpochScopedRelayPtyId } from '../../../../shared/ssh-pending-pty-kill' export type UndeliveredSshPtyKill = { store: Store | undefined @@ -26,9 +27,9 @@ export type UndeliveredSshPtyKill = { * - **reversible**: see above. * - **no `connectionId`**: a local PTY's owner is this process, so a failed kill has no later host * to ask; there is nothing to replay against. - * - **no incarnation**: the replay fence is the host-minted PTY incarnation, and a relay renumbers - * from `pty-1` on every start. An order we could never safely aim can only be discarded later, - * or worse, guessed at. + * - **no incarnation on a legacy id**: a legacy relay renumbers from `pty-1` on every start, so + * without the host-minted incarnation the order could never be safely aimed. A `pty2:` id is + * epoch-scoped and names one process, so it is recorded without one. * - **an id naming another connection**: `getRelayPtyId` throws on those, and this runs inside * promise `.catch` handlers where that would surface as an unhandled rejection. */ export function recordUndeliveredSshPtyKill(args: UndeliveredSshPtyKill): boolean { @@ -36,19 +37,19 @@ export function recordUndeliveredSshPtyKill(args: UndeliveredSshPtyKill): boolea if (!store || !connectionId || args.reversible) { return false } - const incarnationId = args.incarnationId ?? ptyIncarnationById.get(ptyId) - if (!incarnationId) { - return false - } let relayPtyId: string try { relayPtyId = getRelayPtyId(connectionId, ptyId) } catch { return false } + const incarnationId = args.incarnationId ?? ptyIncarnationById.get(ptyId) + if (!incarnationId && !isEpochScopedRelayPtyId(relayPtyId)) { + return false + } store.recordSshRemotePtyKillIntent(connectionId, relayPtyId, { requestedAt: args.now ?? Date.now(), - incarnationId, + ...(incarnationId ? { incarnationId } : {}), attempts: 0 }) return true diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index e5c59d9b81a..be666fc6248 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -10,7 +10,6 @@ import type { PtyRendererDeliveryStateReport } from '../../../shared/pty-renderer-delivery-health' import type { PtyRendererDeliveryDebugSnapshot } from './delivery/debug' -import type { ReplacedPtyStop } from './delivery/exit' import { PtyProducerFlowController } from '../pty-producer-flow-control' import { PtyPendingDataDrainQueue, type PendingPtyData } from '../pty-pending-data-drain-queue' import type { SshPtyOutputIntake } from '../ssh-pty-output-intake' @@ -108,8 +107,6 @@ export type PtyIpcSession = { string, { cleanupTimer: NodeJS.Timeout; incarnationId: string | undefined } > - reversibleStopOwnersByPtyId: Map - replacedPtyStopsById: Map retiredRejectedPtyIds: Map pendingSerializeRequests: Map< string, @@ -238,8 +235,6 @@ export function createPtyIpcSession(args: { sourceCreditPendingPtys: new Set(), backgroundedDeliverySyncByPty: new Map(), syntheticKillExitPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), - replacedPtyStopsById: new Map(), retiredRejectedPtyIds: new Map(), pendingSerializeRequests: new Map(), canSendPtyDataToRenderer: unsetSessionFn, diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index cfc60e7ade6..f1f0c1b14e1 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -33,12 +33,17 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic // Why: a renderer save cannot shrink membership main owns, so each close commits it explicitly. ipcMain.handle( 'session:close-terminal-surface', - (_event, args: { worktreeId?: unknown; target?: unknown } | undefined) => { + (_event, args: { worktreeId?: unknown; target?: unknown; reason?: unknown } | undefined) => { const target = parseTerminalSurfaceCloseTarget(args?.target) if (typeof args?.worktreeId !== 'string' || !target) { throw new Error('invalid_terminal_surface') } - return runtime.closeTerminalSurfaceFromRenderer(args.worktreeId, target) + // Why only these two: main alone closes a tab for its process exit. + return runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: args.worktreeId, + target, + reason: args.reason === 'cleanup' ? 'cleanup' : 'user' + }) } ) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index bce08fd5ece..767c6ece246 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1,3 +1,4 @@ +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. @@ -439,7 +440,9 @@ async function spawnLocalStartupAndSetupTerminals(args: { if (isTuiAgent(createdWithAgent)) { const preset = TUI_AGENT_CONFIG[createdWithAgent].preflightTrust try { - if (preset === 'cursor') { + if (preset === 'qoder') { + markQoderWorkspaceTrusted(worktree.path) + } else if (preset === 'cursor') { markCursorWorkspaceTrusted(worktree.path) } else if (preset === 'copilot') { markCopilotFolderTrusted(worktree.path) diff --git a/src/main/muse/hook-settings.ts b/src/main/muse/hook-settings.ts index 5b5fe3f63fd..0ef3d5d462b 100644 --- a/src/main/muse/hook-settings.ts +++ b/src/main/muse/hook-settings.ts @@ -4,11 +4,11 @@ import { buildManagedCommandHook, createManagedCommandMatcher, getSharedManagedScriptPath, - isPlainObject, wrapPosixHookCommand, wrapWindowsHookCommand, type HookDefinition } from '../agent-hooks/installer-utils' +import { readManagedHookEventsFromJson } from '../agent-hooks/managed-hooks-json-events' const MUSE_SCRIPT_BASE = 'muse-hook' @@ -87,45 +87,9 @@ export function readManagedMuseHookEvents( parsed: unknown, isManagedCommand: (command: string | undefined) => boolean ): Set { - const present = new Set() - if (!isPlainObject(parsed) || !isPlainObject(parsed.hooks)) { - return present - } - for (const event of MUSE_HOOK_EVENTS) { - const definitions = parsed.hooks[event] - if (!Array.isArray(definitions)) { - continue - } - // Why: a hand-edited managed file can hold null definitions, non-array - // hook lists, or null entries — treat all of them as absent so status - // calculation never throws on user content. - if ( - definitions.some((definition) => - managedHookEntries(definition).some((hook) => isManagedCommand(hookEntryCommand(hook))) - ) - ) { - present.add(event) - } - } - return present + return readManagedHookEventsFromJson(parsed, MUSE_HOOK_EVENTS, isManagedCommand) } export function getMuseManagedCommandMatcher(): (command: string | undefined) => boolean { return createManagedCommandMatcher(getMuseManagedScriptFileName()) } - -function managedHookEntries(definition: unknown): readonly unknown[] { - if (!isPlainObject(definition)) { - return [] - } - const hooks = definition.hooks - return Array.isArray(hooks) ? hooks : [] -} - -function hookEntryCommand(hook: unknown): string | undefined { - if (!isPlainObject(hook)) { - return undefined - } - const command = hook.command - return typeof command === 'string' ? command : undefined -} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts index 5f0b2f3bc7a..34e0294b458 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts @@ -153,6 +153,46 @@ describe('agent model catalog store', () => { expect(save).toHaveBeenCalledTimes(2) }) + it("keeps a live child's default effort through a listing that names none, across a restart", async () => { + const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) + const store = new AgentModelCatalogStore() + await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) + const efforts = [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + const listing = (defaultEffort?: string): AgentModelCatalogSuccess => ({ + models: [ + { + id: 'opus', + label: 'Opus', + isDefault: true, + efforts, + ...(defaultEffort ? { defaultEffort } : {}) + } + ], + fastModeTierByModel: new Map(), + origin: 'live-session' + }) + store.recordSuccess('fp', 'claude', listing('medium')) + // A session-less probe never names Claude's default. + store.recordSuccess('fp', 'claude', { ...listing(), origin: 'probe' }) + expect(store.get('fp')!.models[0]!.defaultEffort).toBe('medium') + await store.flushPersistence() + const restarted = new AgentModelCatalogStore() + await restarted.attachPersistence(createAgentModelCatalogFilePersistence(directory)) + expect(restarted.get('fp')!.models[0]!.defaultEffort).toBe('medium') + + // A newer report replaces it; a model that stops offering it drops it. + store.recordSuccess('fp', 'claude', listing('high')) + expect(store.get('fp')!.models[0]!.defaultEffort).toBe('high') + store.recordSuccess('fp', 'claude', { + ...listing(), + models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [efforts[0]!] }] + }) + expect(store.get('fp')!.models[0]).not.toHaveProperty('defaultEffort') + }) + it('persists successes only and hydrates them across a restart', async () => { const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) const store = new AgentModelCatalogStore() diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index eb26d78de5f..dcb6e3935ac 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -53,6 +53,22 @@ function tierRecord(tiers: ReadonlyMap): Record return Object.fromEntries(tiers.entries()) } +/** A listing that names no default effort for a model keeps the one a live child reported for it, + * while that model still offers it: Claude's listing never names one, only a running child does. */ +function withKnownDefaultEfforts( + models: readonly AgentSessionModelOption[], + previous: AgentModelCatalogEntry | undefined +): AgentSessionModelOption[] { + return models.map((model) => { + const known = previous?.models.find((entry) => entry.id === model.id)?.defaultEffort + return model.defaultEffort === undefined && + known !== undefined && + model.efforts.some((choice) => choice.value === known) + ? { ...model, defaultEffort: known } + : { ...model } + }) +} + function listingKey(entry: AgentModelCatalogEntry): string { return JSON.stringify([ entry.origin, @@ -135,16 +151,16 @@ export class AgentModelCatalogStore { // An empty list identifies no model; it is doubt, not a catalog. return null } + const previous = this.entries.get(fingerprint) const entry: AgentModelCatalogEntry = { agent, fingerprint, - models: success.models.map((model) => ({ ...model })), + models: withKnownDefaultEfforts(success.models, previous), ...(success.fastModeSupport ? { fastModeSupport: success.fastModeSupport } : {}), fastModeTierByModel: tierRecord(success.fastModeTierByModel), origin: success.origin, fetchedAt: this.now() } - const previous = this.entries.get(fingerprint) this.entries.delete(fingerprint) this.entries.set(fingerprint, entry) this.failures.delete(fingerprint) 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 d142672f8e5..6cc267a9054 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 @@ -16,7 +16,8 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { dispatchWriteFailureReason } from '../../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { digestPayload } from './journal-payload-bounds' import { reconcileSubmissions, @@ -180,7 +181,7 @@ describe('crash between provider accept and journal commit', () => { await journal.resolveDispatch({ clientMessageId: 'cm_write_failed', state: 'rejected', - reason: dispatchWriteFailureReason(new Error('broken pipe')), + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { surface: 'rejection' }), fence: 1 }) @@ -191,7 +192,7 @@ describe('crash between provider accept and journal commit', () => { // so recovery must not reopen it as doubt. expect(restarted.submissions()[0]).toMatchObject({ dispatchState: 'rejected', - reason: 'provider_write_failed: broken pipe' + reason: 'provider_write_failed' }) expect(restarted.submissions()[0]?.recovered).toBeUndefined() expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) @@ -250,7 +251,9 @@ describe('crash between provider accept and journal commit', () => { await restarted.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', - reason: 'not_delivered', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), fence: 2, recovered: true }) 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 70052865242..668232e607d 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 @@ -4,9 +4,9 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { + classifyDispatchRejection, DISPATCH_REJECTED_WRITE_FAILED, - dispatchRejectionReasonIsInternal, - dispatchRejectionWasTransportWriteFailure + isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import type { openAgentSessionJournal } from './journal-store-factory' @@ -45,7 +45,14 @@ async function settle(reason: string): Promise { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, fence: 1 }) - await journal.resolveDispatch({ clientMessageId: 'msg-1', state: 'rejected', reason, fence: 1 }) + await journal.resolveDispatch({ + clientMessageId: 'msg-1', + // A rejection's reason comes only from `agentSessionFailureWords`; an unknown's is still free + // text (an adapter's error), so it is what reaches the bound. + state: 'unknown', + reason, + fence: 1 + }) return journal.snapshot().submissions[0]?.reason ?? null } @@ -81,7 +88,8 @@ describe('dispatch reason bounding', () => { it('keeps a clipped transport failure classifiable', async () => { const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) expect(stored).not.toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) - expect(dispatchRejectionWasTransportWriteFailure(stored)).toBe(true) - expect(dispatchRejectionReasonIsInternal(stored)).toBe(true) + // Read as an older host wrote it: by the reason alone. + expect(isWriteFailureSubmission({ reason: stored })).toBe(true) + expect(classifyDispatchRejection({ reason: stored })).toMatchObject({ kind: 'writeFailed' }) }) }) 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 new file mode 100644 index 00000000000..470ed3dfcb2 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -0,0 +1,68 @@ +// How a `dispatch` row settles its submission. Field by field, so a key the row gains must be +// copied here to reach any reader. + +import { + readAgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +export function applyJournalDispatchRow( + state: JournalReducerState, + 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') { + 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 + if (rejection) { + submission.rejection = rejection + } else { + delete submission.rejection + } + submission.resolvedAt = row.state === 'pending' ? null : row.ts + if (row.state === 'pending') { + submission.handedOverAt = row.ts + } + if (row.recovered) { + submission.recovered = row.recovered + } else { + delete submission.recovered + } + if (row.state !== 'accepted' || !row.providerItemId) { + return + } + state.aliases.set(row.providerItemId, agentJournalSubmissionKey(row.clientMessageId)) + state.receipts.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + providerItemId: row.providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} + +function unreadFailureFact(value: unknown): UnreadAgentSessionFailureFact | undefined { + return typeof value === 'object' && + value !== null && + 'kind' in value && + typeof value.kind === 'string' && + value.kind + ? { kind: value.kind } + : undefined +} 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 new file mode 100644 index 00000000000..a5427f76fdc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -0,0 +1,159 @@ +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' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { openJournalDatabase } from './journal-database' +import { + classifyJournalOpenFailure, + createJournalOpenReadRefusals, + journalOpenReadRefusal +} from './journal-open-failure' +import { loadJournal } from './journal-open' +import { journalDatabaseFile } from './journal-paths' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-open-failure-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +/** What the journal's own open throws for the file as it stands. */ +function openFailure(): unknown { + try { + loadJournal(root, 'session-1') + } catch (error) { + return error + } + throw new Error('the journal opened') +} + +/** A thrown value shaped as node:sqlite shapes one, with the result code it reports. */ +function nodeSqliteError(errcode: number): Error { + return Object.assign(new Error('sqlite'), { code: 'ERR_SQLITE_ERROR', errcode }) +} + +function systemError(code: string, errno: number): Error { + return Object.assign(new Error(`${code}: open`), { code, errno }) +} + +describe('classifyJournalOpenFailure', () => { + it('calls a journal that is not a database corrupt', async () => { + await writeFile(journalDatabaseFile(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 bytes = await readFile(path) + // Page 1 holds the header and schema; every table's root page follows it. + bytes.fill(0xab, 4096) + await writeFile(path, bytes) + const error = openFailure() + expect(error).toMatchObject({ errcode: 11 }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it.each([ + ['SQLITE_CORRUPT_VTAB', 267], + ['SQLITE_CORRUPT_SEQUENCE', 523], + ['SQLITE_CORRUPT_INDEX', 779] + ])('reads an extended corrupt code as corrupt: %s', (_name, errcode) => { + expect(classifyJournalOpenFailure(nodeSqliteError(errcode))).toBe('journalCorrupt') + }) + + it("reads the Bun driver's corrupt code as corrupt", () => { + const error = Object.assign(new Error('file is not a database'), { + name: 'SQLiteError', + code: 'SQLITE_NOTADB', + errno: 26 + }) + expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') + }) + + it('finds corruption a wrapper names as its cause', () => { + const wrapped = new Error('opening the conversation failed', { + cause: new Error('the journal would not open', { cause: nodeSqliteError(11) }) + }) + expect(classifyJournalOpenFailure(wrapped)).toBe('journalCorrupt') + }) + + it.each([ + ['a busy database', nodeSqliteError(5)], + ['a locked database', nodeSqliteError(6)], + ['a database SQLite cannot open', nodeSqliteError(14)], + ['a disk I/O error', nodeSqliteError(10)], + ['permission denied', systemError('EACCES', -13)], + ['too many open files', systemError('EMFILE', -24)], + ['a Windows error whose low byte reads as corrupt', systemError('EUNKNOWN', -4085)], + ['an error that only claims a corrupt code', Object.assign(new Error('x'), { errcode: 11 })], + ['a thrown string', 'database disk image is malformed'], + ['nothing at all', undefined] + ])('calls any other failure one that can clear: %s', (_label, error) => { + expect(classifyJournalOpenFailure(error)).toBe('journalUnavailable') + }) + + it('stops on a cause chain that loops back on itself', () => { + const first = new Error('first') + const second = new Error('second', { cause: first }) + Object.assign(first, { cause: second }) + expect(classifyJournalOpenFailure(first)).toBe('journalUnavailable') + }) +}) + +describe('journalOpenReadRefusal', () => { + it('names the reason, keeps the message the code and the storage text only as the cause', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const storage = nodeSqliteError(26) + const refusal = journalOpenReadRefusal(storage) + expect(refusal.message).toBe('agent_session_journal_unreadable') + expect(refusal.refusal).toMatchObject({ + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + }) + expect(refusal.cause).toBe(storage) + vi.restoreAllMocks() + }) + + it('passes a refusal the open already raised through unchanged', () => { + const raised = agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + expect(journalOpenReadRefusal(raised)).toBe(raised) + }) +}) + +describe('createJournalOpenReadRefusals', () => { + it('logs a session once per failure until it opens, and each session on its own', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const refusals = createJournalOpenReadRefusals() + const denied = systemError('EACCES', -13) + const corrupt = nodeSqliteError(26) + + for (let attempt = 0; attempt < 3; attempt += 1) { + const refusal = refusals.refusal('session-1', denied) + expect(refusal.refusal).toMatchObject({ details: { reason: 'journalUnavailable' } }) + expect(refusal.cause).toBe(denied) + } + expect(warn).toHaveBeenCalledTimes(1) + refusals.refusal('session-2', denied) + expect(warn).toHaveBeenCalledTimes(2) + expect(refusals.refusal('session-1', corrupt).refusal).toMatchObject({ + details: { reason: 'journalCorrupt' } + }) + expect(warn).toHaveBeenCalledTimes(3) + refusals.forget('session-1') + refusals.refusal('session-1', corrupt) + expect(warn).toHaveBeenCalledTimes(4) + 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 new file mode 100644 index 00000000000..5e4cff1f3ee --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -0,0 +1,80 @@ +// 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). + +import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' +import { + AgentSessionRefusalError, + isAgentSessionRefusalError, + refuse +} from '../../../shared/agent-session-wire-refusals' +import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' + +export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> + +// 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. */ +export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { + let current = error + for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { + if (isSqliteCorruption(current)) { + return 'journalCorrupt' + } + current = current instanceof Error ? current.cause : undefined + } + return 'journalUnavailable' +} + +/** + * 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, + * whose message stays the bare code. + */ +export function journalOpenReadRefusal(error: unknown): AgentSessionRefusalError { + if (isAgentSessionRefusalError(error)) { + return error + } + return unreadableRefusal(error, classifyJournalOpenFailure(error), true) +} + +const MAX_LOGGED_SESSIONS = 256 + +/** + * The read door's refusals for one host. A reader reconnects on a timer while an open can clear, + * so a session's failure is logged once until that session opens or the failure changes. + */ +export function createJournalOpenReadRefusals() { + const logged = new Map() + return { + refusal: (sessionId: string, error: unknown): AgentSessionRefusalError => { + if (isAgentSessionRefusalError(error)) { + return error + } + const reason = classifyJournalOpenFailure(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. + if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { + logged.set(sessionId, failure) + } + return unreadableRefusal(error, reason, !repeat) + }, + /** The session opened or closed: its next failure is news. */ + forget: (sessionId: string): void => { + logged.delete(sessionId) + } + } +} + +function unreadableRefusal( + error: unknown, + reason: JournalOpenFailure, + log: boolean +): AgentSessionRefusalError { + if (log) { + console.warn('[agent-session] opening the conversation for a read failed:', error) + } + const code = 'agent_session_journal_unreadable' + return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index c356d7904b2..c252a57d8a5 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -1,3 +1,4 @@ +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' @@ -40,7 +41,7 @@ export async function markJournalPendingSubmissionsUnknown( export async function rejectJournalPendingSubmissions( journal: AgentSessionJournal, fence: number, - reason: string + rejection: AgentJournalDispatchRejection ): Promise { const unwritten = journal .submissions() @@ -54,7 +55,7 @@ export async function rejectJournalPendingSubmissions( await journal.resolveDispatch({ clientMessageId: entry.clientMessageId, state: 'rejected', - reason, + ...rejection, fence, recovered: true }) @@ -66,7 +67,7 @@ export async function rejectJournalPendingSubmissions( export async function rejectJournalQueuedSubmissions( journal: AgentSessionJournal, fence: number, - reason: string, + rejection: AgentJournalDispatchRejection, which: (submission: AgentJournalSubmission) => boolean = () => true ): Promise { const queued = journal @@ -76,7 +77,7 @@ export async function rejectJournalQueuedSubmissions( await journal.resolveDispatch({ clientMessageId: entry.clientMessageId, state: 'rejected', - reason, + ...rejection, fence, recovered: true }) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts new file mode 100644 index 00000000000..3332e53b0b3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts @@ -0,0 +1,100 @@ +// A dispatch row's typed rejection fact, as the reducer folds it onto the submission. + +import { describe, expect, it } from 'vitest' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_QUEUE_FULL +} from '../../../shared/structured-agent-session-dispatch-rejection' +import { + applyJournalRow, + createJournalReducerState, + type JournalReducerState +} from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +const EPOCH = 'epoch-1' + +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 } +} + +function fold(rows: JournalRow[]): JournalReducerState { + const state = createJournalReducerState('session-1', EPOCH) + for (const row of rows) { + applyJournalRow(state, row) + } + return state +} + +describe('a rejected dispatch', () => { + const submission: JournalRow = { + kind: 'submission', + clientMessageId: 'cm_1', + payloadFingerprint: 'fp_1', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...base(1) + } + + it("copies a rejection's typed fact onto the submission, and only on `rejected`", () => { + const rejection = { kind: 'providerRejected', detail: { text: 'Too long', audience: 'person' } } + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected', detail: { text: 'Too long', audience: 'person' } }, + ...base(2) + } + ]) + expect(state.submissions.get('cm_1')).toMatchObject({ + dispatchState: 'rejected', + reason: 'The provider did not accept this message.', + rejection + }) + + const doubt = fold([ + { ...submission, clientMessageId: 'cm_2' }, + { + kind: 'dispatch', + clientMessageId: 'cm_2', + state: 'unknown', + providerItemId: null, + reason: 'provider_exited_before_acknowledgement', + rejection: { kind: 'writeFailed' }, + ...base(2) + } + ]) + expect(doubt.submissions.get('cm_2')).not.toHaveProperty('rejection') + }) + + it('keeps only the kind of a rejection fact it cannot place, so it reads as no verdict', () => { + const rejected = (rejection: unknown): JournalRow => { + const row: JournalRow = { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_QUEUE_FULL, + ...base(2) + } + // A row read from disk carries whatever the host that wrote it did. + return Object.assign(row, { rejection }) + } + // A newer host's kind: the marker beside it must not decide. + const newer = fold([submission, rejected({ kind: 'futureKind', detail: 'x' })]) + const settled = newer.submissions.get('cm_1') + expect(settled).toMatchObject({ dispatchState: 'rejected', rejection: { kind: 'futureKind' } }) + expect(settled && classifyDispatchRejection(settled)).toEqual({ + category: 'undelivered', + verdict: null + }) + // Not a fact at all: dropped, leaving the reason. + const malformed = fold([submission, rejected('queueFull')]).submissions.get('cm_1') + expect(malformed).not.toHaveProperty('rejection') + expect(malformed && classifyDispatchRejection(malformed)).toMatchObject({ kind: 'queueFull' }) + }) +}) 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 825c5aaf347..eea740e754d 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 @@ -118,6 +118,39 @@ describe('ordering', () => { expect(renderJournalState(state).items.map((item) => item.itemId)).toEqual(['earlier', 'later']) }) + it("places a batch's writes by their order in it, and keeps that place on revision", () => { + // One Codex ask writes all its questions in one batch; their ids are not their order. + const state = fold([ + { + kind: 'lifecycle-batch', + settlementId: 'ask', + mutations: [ + { kind: 'item', itemId: 'scope', revision: 1, body: text('first') }, + { kind: 'item', itemId: 'priority', revision: 1, body: text('second') }, + { kind: 'item', itemId: 'deadline', revision: 1, body: text('third') } + ], + ...base(1) + }, + { + kind: 'lifecycle-batch', + settlementId: 'answer', + mutations: [{ kind: 'item', itemId: 'deadline', revision: 2, body: text('answered') }], + ...base(2) + } + ]) + expect( + renderJournalState(state).items.map(({ itemId, sequence, sequenceIndex }) => ({ + itemId, + sequence, + sequenceIndex + })) + ).toEqual([ + { itemId: 'scope', sequence: 1, sequenceIndex: undefined }, + { itemId: 'priority', sequence: 1, sequenceIndex: 1 }, + { itemId: 'deadline', sequence: 1, sequenceIndex: 2 } + ]) + }) + it('orders by sequence even when the observed timestamp runs backwards', () => { const state = fold([ { kind: 'item', itemId: 'late', revision: 1, body: text('late'), ...base(1), ts: 9_000 }, 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 3fbee85a417..7ce1f4ef1c1 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -4,9 +4,10 @@ // // Rules: highest revision wins, a tombstone removes, a late lower revision is // dropped rather than resurrecting stale content, and ordering is by the -// sequence of the row 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. +// 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. import type { AgentJournalAcceptanceReceipt, @@ -15,6 +16,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { journalBatchMutationProducer, journalRenderItem } from './journal-render-item' +import { compareAgentJournalItems } from '../../../shared/agent-session-journal-position' import { agentJournalSubmissionKey, parseAgentJournalItemKey @@ -26,7 +28,8 @@ import { import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { journalItemRevisionIsStale } from './journal-item-revision' import type { JournalRow } from './journal-row-schema' -import { dispatchRejectionWasTransportWriteFailure } from '../../../shared/structured-agent-session-dispatch-rejection' +import { applyJournalDispatchRow } from './journal-dispatch-reducer' +import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -90,25 +93,17 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo if (state.appliedSettlementIds.has(row.settlementId)) { return } - for (const mutation of row.mutations) { + for (const [sequenceIndex, mutation] of row.mutations.entries()) { if (mutation.kind === 'item') { if (journalItemRevisionIsStale(state, mutation.itemId, mutation.revision)) { continue } - const itemId = resolveJournalItemId(state, mutation.itemId, mutation.body) + const { revision, body } = mutation + const itemId = resolveJournalItemId(state, mutation.itemId, body) acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row) - upsertItem( - state, - itemId, - mutation.revision, - journalRenderItem( - itemId, - mutation.revision, - mutation.body, - row, - journalBatchMutationProducer(row, mutation) - ) - ) + const producer = journalBatchMutationProducer(row, mutation) + const item = journalRenderItem(itemId, revision, body, row, producer, sequenceIndex) + upsertItem(state, itemId, revision, item) } else { removeItem(state, resolveItemId(state, mutation.itemId), mutation.revision) } @@ -120,7 +115,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo applySubmission(state, row) return } - applyDispatch(state, row) + applyJournalDispatchRow(state, row) } export function rememberAppliedSettlementId( @@ -172,7 +167,7 @@ export function resolveJournalItemId( .find( (candidate) => candidate.dispatchState !== 'rejected' && - !dispatchRejectionWasTransportWriteFailure(candidate.reason) && + !isWriteFailureSubmission(candidate) && candidate.payloadFingerprint === fingerprint && state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0 ) @@ -216,14 +211,16 @@ function upsertItem( existing.body.kind === 'message' && existing.body.role === 'user' && parseAgentJournalItemKey(itemId)?.provider === 'orca' + const { sequenceIndex: _revisedAt, ...revised } = next state.items.set(itemId, { - ...next, + ...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) @@ -261,43 +258,6 @@ function applySubmission( upsertItem(state, itemId, 0, journalRenderItem(itemId, 0, row.body, row)) } -function applyDispatch( - state: JournalReducerState, - 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') { - return - } - submission.fence = row.fence - submission.dispatchState = row.state - submission.providerItemId = row.providerItemId - submission.reason = row.reason - submission.resolvedAt = row.state === 'pending' ? null : row.ts - if (row.state === 'pending') { - submission.handedOverAt = row.ts - } - if (row.recovered) { - submission.recovered = row.recovered - } else { - delete submission.recovered - } - if (row.state !== 'accepted' || !row.providerItemId) { - return - } - state.aliases.set(row.providerItemId, agentJournalSubmissionKey(row.clientMessageId)) - state.receipts.set(row.clientMessageId, { - clientMessageId: row.clientMessageId, - providerItemId: row.providerItemId, - cursor: { epoch: row.epoch, sequence: row.seq }, - acceptedAt: row.ts - }) -} - function acceptSubmissionFromProviderItem( state: JournalReducerState, providerItemId: string, @@ -333,9 +293,9 @@ function acceptSubmissionFromProviderItem( /** Project the folded state into the client-facing snapshot. */ export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { - // Sequence is the sole ordering key; map insertion order is not, because a - // re-created item re-enters the map after the items that followed it. - const items = [...state.items.values()].sort((a, b) => a.sequence - b.sequence) + // The journal position is the sole ordering key; map insertion order is not, + // because a re-created item re-enters the map after the items that followed it. + const items = [...state.items.values()].sort(compareAgentJournalItems) return { sessionId: state.sessionId, cursor: { epoch: state.epoch, sequence: state.lastSequence }, 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 6f41ff3e862..516086eed3d 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 @@ -19,13 +19,16 @@ export function journalRenderItem( revision: number, body: AgentJournalItemBody, row: JournalRow, - producer: AgentJournalProducerLinkage = row + producer: AgentJournalProducerLinkage = row, + /** Which of the row's writes this is; only a lifecycle batch has more than one. */ + sequenceIndex = 0 ): AgentJournalRenderItem { return { itemId, revision, body, sequence: row.seq, + ...(sequenceIndex > 0 ? { sequenceIndex } : {}), observedAt: row.ts, ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), 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 46d161b1172..4428e13953e 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 @@ -15,6 +15,9 @@ 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 { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -122,7 +125,18 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { expect(settled).toEqual(['cm_1']) const submission = journal.submissions()[0] expect(submission?.dispatchState).toBe('rejected') - expect(submission?.reason).toBe('not_delivered') + // A sentence, since released clients print the reason as it is, and the fact beside it. + expect(submission?.reason).toBe( + agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) + .reason + ) + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) + // Nobody failed: the crash stranded it before the provider took it. + expect(submission && classifyDispatchRejection(submission)).toEqual({ + category: 'undelivered', + verdict: null, + kind: 'notDelivered' + }) }) it('leaves a submission unknown while the provider reports a turn in flight', async () => { @@ -254,7 +268,7 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { 'accepted', 'rejected' ]) - expect(restarted.submissions()[1]?.reason).toBe('not_delivered') + expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) }) it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { 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 f37571814bb..7b36fc88dc6 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 @@ -10,6 +10,8 @@ // through the user's Retry, which rotates the client message id; Orca still // never puts a message back on the wire on the user's behalf. +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -116,7 +118,9 @@ export async function reconcileJournalSubmissionsAgainstHistory(input: { : { clientMessageId: outcome.clientMessageId, state: 'rejected', - reason: outcome.reason, + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), fence: input.fence, recovered: true } 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 e56ca86a7fb..ee56f28f8a2 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 @@ -1,5 +1,4 @@ import type { - AgentJournalDispatchState, AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalMessageItem, @@ -78,23 +77,22 @@ export function journalDispatchRowBuilder( ): RowBuilder { const providerItemId = input.state === 'accepted' ? agentJournalItemKey(input.providerIdentity) : null - return (seq, ts) => - buildJournalDispatchRow({ - state: state(), - clientMessageId: input.clientMessageId, - dispatchState: input.state, - providerItemId, - reason: boundedDispatchReason(input), - seq, - fence: input.fence, - ts, - recovered: input.recovered - }) + // The only dispatch-row builder: its input type is what makes a rejected row carry its fact. + return (seq, ts) => ({ + kind: 'dispatch', + clientMessageId: input.clientMessageId, + state: input.state, + providerItemId, + reason: boundedDispatchReason(input), + ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...journalRowBase(state().epoch, seq, input.fence, ts), + ...(input.recovered ? { recovered: input.recovered } : {}) + }) } /** `reason` is the only unbounded field written by Orca's own code: a provider error is * arbitrary text, and a multi-megabyte one reached the row verbatim. Bounded head-first, - * because `dispatchRejectionWasTransportWriteFailure` prefix-matches the value. Rows + * because `isWriteFailureSubmission` prefix-matches the value. Rows * written before this keep their full text, so readers still meet unbounded ones. */ function boundedDispatchReason(input: ResolveDispatchInput): string | null { if (input.state === 'accepted' || input.state === 'pending' || !input.reason) { @@ -280,25 +278,3 @@ export function buildJournalSubmissionRow(input: { ...(input.handoverRecorded ? { handoverRecorded: true } : {}) } } - -export function buildJournalDispatchRow(input: { - state: JournalReducerState - clientMessageId: string - dispatchState: AgentJournalDispatchState - providerItemId: string | null - reason: string | null - seq: number - fence: number - ts: number - recovered?: true -}): JournalDispatchRow { - return { - kind: 'dispatch', - clientMessageId: input.clientMessageId, - state: input.dispatchState, - providerItemId: input.providerItemId, - reason: input.reason, - ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), - ...(input.recovered ? { recovered: input.recovered } : {}) - } -} 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 0f29f8b3879..786d422876c 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 @@ -5,6 +5,7 @@ // UNREADABLE, not skippable: the caller must degrade to read-only rather than // render a partial timeline or compact past a row it cannot interpret. +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalDispatchState, @@ -89,6 +90,9 @@ export type JournalDispatchRow = JournalRowBase & { /** Provider item identity adopted on accept. */ providerItemId: string | null reason: string | null + /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is + * dropped when read, never the row. */ + rejection?: AgentSessionFailureFact } /** An item mutation may name its own producer, because one batch can CREATE 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 index 960c65a638a..af53cd16d3d 100644 --- 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 @@ -13,6 +13,8 @@ import type { 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' @@ -117,7 +119,16 @@ describe('closed-state admission happens at enqueue', () => { fence: 1 }) ), - settle(journal.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', fence: 1 })), + settle( + journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { + surface: 'rejection' + }), + fence: 1 + }) + ), settle( journal.appendLifecycleBatch({ settlementId: 'settle', 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 79d86f80dbb..c2210efb253 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 @@ -1,3 +1,4 @@ +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentJournalCursor, AgentJournalItemBody, @@ -31,7 +32,10 @@ export type ResolveDispatchInput = { } & ( | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } | { state: 'pending' } - | { state: 'rejected' | 'unknown'; reason?: string | null } + /** `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 = { 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 5119f7ef597..45c42b45f8a 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,5 +1,6 @@ // Append-only journal store for one agent session. +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { randomUUID } from 'node:crypto' import type { AgentJournalAcceptanceReceipt, @@ -319,17 +320,20 @@ export class AgentSessionJournal { } /** Reject unanswered sends after an owner that never proved its start ended: none was written. */ - async rejectPendingSubmissions(fence: number, reason: string): Promise { - return rejectJournalPendingSubmissions(this, fence, reason) + async rejectPendingSubmissions( + fence: number, + rejection: AgentJournalDispatchRejection + ): Promise { + return rejectJournalPendingSubmissions(this, fence, rejection) } /** Reject sends accepted but never handed over, optionally only those `which` names. */ async rejectQueuedSubmissions( fence: number, - reason: string, + rejection: AgentJournalDispatchRejection, which?: (submission: AgentJournalSubmission) => boolean ): Promise { - return rejectJournalQueuedSubmissions(this, fence, reason, which) + return rejectJournalQueuedSubmissions(this, fence, rejection, which) } /** The escape hatch for corruption, an unreconcilable prefix, a forked handle, diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index 57f2291e476..b70a18a8386 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -6,6 +6,7 @@ // key instead of appearing as a second copy of the user's own message. import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { compareAgentJournalItems } from '../../../shared/agent-session-journal-position' import type { AgentJournalRenderItem, AgentJournalSnapshot, @@ -65,7 +66,7 @@ export function projectJournalBatch(input: { const items = [...touchedItemIds] .map((itemId) => live.get(itemId)) .filter((item) => item !== undefined) - .sort((a, b) => a.sequence - b.sequence) + .sort(compareAgentJournalItems) return { ok: true, batch: { 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 b9e1b49c213..fb4ee607e38 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 @@ -2,21 +2,21 @@ // 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. +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' 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 { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent, AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' import { - DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTED_HOST_RESTARTED, - DISPATCH_REJECTED_PROVIDER_CLOSED + classifyDispatchRejection, + 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' @@ -29,16 +29,20 @@ import { import { journalIdentityFor } from './structured-agent-session-attach' import { attachParamsForRecord } from './structured-agent-session-conversation-open' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionChildEndCause } from './structured-agent-session-host-types' import { persistRewindRecord } from './structured-rewind-recovery' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, hostTestAttachParams, + hostTestDrawnRowIds, hostTestMessage, hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CALLER = { callerKey: 'client-1' } @@ -52,7 +56,7 @@ let host: StructuredAgentSessionHost let acquire: Mock let dispatch: Mock let adapterExtras: Partial -let releaseGraceMs: number +let idleMs: number const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, @@ -84,7 +88,7 @@ async function startHost(): Promise { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - releaseGraceMs, + idleSweep: { intervalMs: 5, idleMs }, now: () => NOW }) } @@ -93,7 +97,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-accept-deliver-')) resetHostTestOperationIds() adapterExtras = {} - releaseGraceMs = 60_000 + idleMs = 60 * 60_000 acquire = vi.fn(spawnChild) dispatch = vi.fn(async () => ({ state: 'accepted' as const, @@ -158,8 +162,10 @@ function stop() { }) } -function submission(id: string): AgentJournalSubmission | undefined { - return host.journalSnapshot(SESSION).submissions.find((entry) => entry.clientMessageId === id) +async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) } /** Read back after the conversation was closed, through the same open any reader takes. */ @@ -168,21 +174,25 @@ async function reopened(id: string): Promise return submission(id) } -function errorRows(): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) +async function errorRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) +} + +async function errorFailures(): Promise<(AgentSessionFailureFact | undefined)[]> { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.failure] : [] + ) } let subscriptions = 0 -function subscribe(): AgentSessionSubscribeEvent[] { +async function subscribe(): Promise { const events: AgentSessionSubscribeEvent[] = [] subscriptions += 1 // Cloned as received: a frame shares the journal's live objects, which later rows revise. - host.subscribe({ + await host.subscribe({ id: `sub-${subscriptions}`, sessionId: SESSION, emit: (event) => events.push(structuredClone(event)) @@ -267,14 +277,16 @@ describe('a send is answered at acceptance', () => { }) const id = await accept('hello') - const events = subscribe() - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + const events = await subscribe() + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) expect(dispatch).not.toHaveBeenCalled() starting.resolve() - await eventually(() => expect(framedStates(events, id)).toEqual(['handed-over'])) + await eventually(async () => expect(framedStates(events, id)).toEqual(['handed-over'])) answering.resolve() - await eventually(() => expect(framedStates(events, id)).toEqual(['handed-over', 'accepted'])) + await eventually(async () => + expect(framedStates(events, id)).toEqual(['handed-over', 'accepted']) + ) }) it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { @@ -285,7 +297,7 @@ describe('a send is answered at acceptance', () => { return spawnChild(input) }) const first = await accept('first') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) const second = host.send(CALLER, sendParams('second')) starting.resolve() @@ -294,11 +306,15 @@ describe('a send is answered at acceptance', () => { throw new Error('the second send was refused') } const secondId = secondResult.value.clientMessageId - await eventually(() => expect(submission(secondId)?.dispatchState).toBe('accepted')) + await eventually(async () => + expect((await submission(secondId))?.dispatchState).toBe('accepted') + ) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, secondId]) // The second was accepted while the start held the queue — before the first was handed over. - expect(submission(secondId)!.submittedAt).toBeLessThanOrEqual(submission(first)!.handedOverAt!) - const rows = host.journalSnapshot(SESSION).submissions + expect((await submission(secondId))!.submittedAt).toBeLessThanOrEqual( + (await submission(first))!.handedOverAt! + ) + const rows = (await host.journalSnapshot(SESSION)).submissions expect(rows.map((row) => row.clientMessageId)).toEqual([first, secondId]) }) }) @@ -310,16 +326,47 @@ describe('a start the chat needed and did not get', () => { const first = await accept('first') const second = await accept('second') - await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) - const rows = errorRows() + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + const rows = await errorRows() expect(rows).toHaveLength(1) - expect(rows[0]).toContain('spawn codex ENOENT') - expect(submission(first)).toMatchObject({ dispatchState: 'rejected', reason: rows[0] }) - expect(submission(second)).toMatchObject({ dispatchState: 'rejected', reason: rows[0] }) + // Orca's spawn error goes to the log; the row and every message say what failed, typed. The + // child is gone, but no exit was observed, so nothing blames the provider. + expect(rows[0]).toBe("Codex couldn't restart. Send your message to try again.") + const failure = { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + expect(await errorFailures()).toEqual([failure]) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: rows[0], + rejection: failure + }) + } const next = await accept('after the fix') - await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) - expect(errorRows()).toHaveLength(1) + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) + expect(await errorRows()).toHaveLength(1) + }) + + it('draws the messages it failed above the error row, since they were accepted first', async () => { + await host.close(SESSION) + acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + const first = await accept('first') + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + + const snapshot = await host.journalSnapshot(SESSION) + const errorRow = snapshot.items.find( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + )?.itemId + const shown = [agentJournalSubmissionKey(first), agentJournalSubmissionKey(second), errorRow] + const drawn = hostTestDrawnRowIds(snapshot, [ + { clientMessageId: first, text: 'first' }, + { clientMessageId: second, text: 'second' } + ]) + expect(drawn.filter((id) => shown.includes(id))).toEqual(shown) }) it('notifies failed once for the queued messages one start failure refused', async () => { @@ -330,7 +377,7 @@ describe('a start the chat needed and did not get', () => { await accept('first') const second = await accept('second') - await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) await host.flushAllStreamedEvents() expect(completions).toEqual([ { @@ -350,12 +397,27 @@ describe('a start the chat needed and did not get', () => { () => { adapterExtras = { supportsLocation: () => false } }, - 'cannot resume' + { + text: "Codex couldn't restart. Start a new chat to continue.", + failure: { + kind: 'restartFailed', + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + } + } ], [ 'spawn', () => acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')), - 'spawn codex ENOENT' + { + text: "Codex couldn't restart. Send your message to try again.", + failure: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + } ], [ 'auth', @@ -363,19 +425,26 @@ describe('a start the chat needed and did not get', () => { acquire.mockRejectedValueOnce( new AgentSessionPreSpawnError(new Error('Not logged in. Please run /login.')) ), - 'Not logged in' + { + // No process ever started, so nothing says the provider stopped. + text: "Codex couldn't restart. Send your message to try again.", + failure: { + kind: 'restartFailed', + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + } ] - ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, cause) => { + ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, row) => { await host.close(SESSION) arrange() await host.flushAllStreamedEvents() await startHost() const id = await accept('hello') - const events = subscribe() + const events = await subscribe() - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(errorRows()).toHaveLength(1) - expect(errorRows()[0]).toContain(cause) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await errorRows()).toEqual([row.text]) + expect(await errorFailures()).toEqual([row.failure]) const framedRows = events.flatMap((event) => event.type === 'batch' || event.type === 'snapshot' ? (event.type === 'batch' ? event.batch.items : event.page.items).filter( @@ -406,12 +475,14 @@ describe('a start the chat needed and did not get', () => { settled = await reopened(id) expect(settled?.dispatchState).not.toBe('pending') }) - // The message names the start that failed, not a close or a restart it never met. + // The message names the start that failed, not a close or a restart it never met — and never + // the store's own error, which is Orca's and goes to the log. expect(settled).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining('record store write failed') + rejection: (await errorFailures())[0] }) - expect(errorRows()).toEqual([settled?.reason]) + expect(settled?.reason).not.toContain('record store write failed') + expect(await errorRows()).toEqual([settled?.reason]) }) }) @@ -437,7 +508,7 @@ describe('an attach that fails after indexing its child', () => { } return AgentSessionRecordStore.prototype.recordOperationOutcome.call(store, input) }) - await eventually(() => expect(submission(first)?.dispatchState).toBe('rejected')) + await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) // Nothing was indexed, so nothing had to be taken back: no list ever showed a child. expect(host['sessions'].get(SESSION)?.child).toBeNull() expect(owned).not.toContain(true) @@ -445,7 +516,7 @@ describe('an attach that fails after indexing its child', () => { const next = await accept('after the failure') - await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) expect(acquire).toHaveBeenCalledTimes(acquiresBefore + 1) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) }) @@ -459,9 +530,11 @@ describe('what an earlier host process left behind', () => { await host.revealSession(SESSION) - expect(submission('queued')).toMatchObject({ + expect(await submission('queued')).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_HOST_RESTARTED + ...agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) }) expect(dispatch).not.toHaveBeenCalled() }) @@ -479,8 +552,8 @@ describe('what an earlier host process left behind', () => { await host.revealSession(SESSION) - expect(submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true }) - expect(submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(await submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(await submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true }) // Deciding them from provider history waits for a won lease (W4′d). expect(providerHistoryWindow).not.toHaveBeenCalled() expect(dispatch).not.toHaveBeenCalled() @@ -506,13 +579,65 @@ describe('a child that exits before its message is handed over', () => { const id = await accept('hello') - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(submission(id)?.reason).toContain('codex app-server crashed') + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await submission(id)).toMatchObject({ + reason: 'Codex stopped before this message was sent.', + rejection: { kind: 'providerExited' } + }) expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() }) }) +describe('a start whose failure the delivery loop settles before the exit is published', () => { + it('keeps one row in the words its rejected messages carry', async () => { + // The adapter's startup answer and its later exit event word the same start differently. + const awaitStarted = vi.fn(async () => agentSessionFailureFact('startFailed')) + adapterExtras = { awaitStarted } + acquire.mockImplementation(async (input) => ({ + ...(await spawnChild(input)), + providerChildPhase: 'starting' as const + })) + await host.close(SESSION) + await startHost() + + const first = await accept('first') + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reason: 'codex app-server exited with code 1', + failure: agentSessionFailureFact('providerStartFailed', { + detail: { text: 'codex: config.toml is invalid', audience: 'person' } + }), + cause: 'unexpected-exit', + startupUnproven: true + }) + await host.flushStreamedEvents(SESSION) + + const rows = (await host.journalSnapshot(SESSION)).items.filter((item) => + item.itemId.includes('start-failure') + ) + expect(rows).toHaveLength(1) + const words = agentSessionFailureWords(agentSessionFailureFact('startFailed'), { + surface: 'rejection', + agentName: 'Codex', + provider: 'codex' + }) + expect(rows[0].body).toMatchObject({ text: words.reason, failure: words.rejection }) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: words.reason, + rejection: words.rejection + }) + } + }) +}) + describe('Stop withdraws what is queued', () => { it('withdraws a crash leftover ahead of any delivery step (W17a)', async () => { await writeAsEarlierProcess(async (journal, fence) => { @@ -522,7 +647,7 @@ describe('Stop withdraws what is queued', () => { // Stop's own open wakes the delivery loop, whose first step queues behind this Stop. expect(await stop()).toMatchObject({ ok: true }) - expect(submission('leftover')).toMatchObject({ + expect(await submission('leftover')).toMatchObject({ dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) @@ -537,14 +662,14 @@ describe('Stop withdraws what is queued', () => { return spawnChild(input) }) const id = await accept('hello') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) const stopped = stop() starting.resolve() expect(await stopped).toMatchObject({ ok: true }) - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(submission(id)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) - expect(submission(id)?.handedOverAt).toBeUndefined() + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + expect(await submission(id)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) + expect((await submission(id))?.handedOverAt).toBeUndefined() expect(dispatch).not.toHaveBeenCalled() }) @@ -563,7 +688,7 @@ describe('Stop withdraws what is queued', () => { providerChildPhase: 'starting' as const })) const id = await accept('hello') - await eventually(() => expect(awaitStarted).toHaveBeenCalled()) + await eventually(async () => expect(awaitStarted).toHaveBeenCalled()) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -575,7 +700,7 @@ describe('Stop withdraws what is queued', () => { // A loop still waiting on that start would swallow this send; it starts a new child instead. awaitStarted.mockImplementation(async () => undefined) const next = await accept('after stop') - await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) expect(dispatch).toHaveBeenCalledTimes(1) }) }) @@ -586,16 +711,15 @@ describe('an eviction between acceptance and handover', () => { adapterExtras = { awaitStarted: () => started.promise } await host.close(SESSION) await startHost() - await host.hold(SESSION, 'surface-1') const id = await accept('hello') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) // Between the delivery loop's start step and its handover step. await host.close(SESSION) started.resolve() expect(await reopened(id)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) expect(dispatch).not.toHaveBeenCalled() }) @@ -606,45 +730,86 @@ describe('an eviction between acceptance and handover', () => { adapterExtras = { awaitStarted } await host.close(SESSION) await startHost() - await host.hold(SESSION, 'surface-1') dispatch.mockResolvedValueOnce({ state: 'admitted' }) const handed = await accept('handed over') - await eventually(() => expect(submission(handed)?.handedOverAt).toBeDefined()) + await eventually(async () => expect((await submission(handed))?.handedOverAt).toBeDefined()) awaitStarted.mockImplementation(() => second.promise) const queued = await accept('still queued') - await eventually(() => expect(awaitStarted).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(awaitStarted).toHaveBeenCalledTimes(2)) await host.close(SESSION) second.resolve() expect(await reopened(queued)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) - expect(submission(handed)).toMatchObject({ dispatchState: 'unknown' }) + expect(await submission(handed)).toMatchObject({ dispatchState: 'unknown' }) expect(dispatch).toHaveBeenCalledTimes(1) }) - it('does not evict an idle child while a message is still queued for it (W24)', async () => { - releaseGraceMs = 0 + it('does not stop an idle child while a message is still queued for it (W24)', async () => { + idleMs = 0 const started = deferred() adapterExtras = { awaitStarted: () => started.promise } await host.close(SESSION) await startHost() - await host.hold(SESSION, 'surface-1') const id = await accept('hello') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - host.release(SESSION, 'surface-1') + await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + // The idle sweep ticks every few milliseconds meanwhile. await new Promise((resolve) => setTimeout(resolve, 20)) started.resolve() // Handed to the child it was queued for; the eviction may follow once nothing is owed. - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(1)) expect(dispatch.mock.calls[0]?.[0].clientMessageId).toBe(id) expect(acquire).toHaveBeenCalledTimes(2) }) }) +// A close abandons what is queued before it stops the child, so a release that then fails still +// leaves every queued message rejected as closed, never blamed on the provider. +describe('a close that stops the child and then fails', () => { + const END_CHILD = { + evict: () => host.close(SESSION) + } satisfies Partial Promise>> + + it.each([ + { end: 'evict', starting: true, kind: 'chatClosed', verdict: null }, + { end: 'evict', starting: false, kind: 'chatClosed', verdict: null } + ] as const)( + 'rejects what is queued as $kind after a $end (during startup: $starting)', + async ({ end, starting, kind, verdict }) => { + const started = deferred() + adapterExtras = { + awaitStarted: () => started.promise, + // Once: the host's own teardown acknowledges again. + acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { + throw new Error('release acknowledgement failed') + }) + } + await host.close(SESSION) + await startHost() + acquire.mockImplementationOnce(async (input) => ({ + ...(await spawnChild(input)), + ...(starting ? { providerChildPhase: 'starting' as const } : {}) + })) + const id = await accept('hello') + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + + await expect(END_CHILD[end]()).rejects.toThrow() + expect(host.hasSession(SESSION)).toBe(true) + started.resolve() + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + const rejected = (await submission(id))! + expect(rejected.rejection).toMatchObject({ kind }) + expect(classifyDispatchRejection(rejected).verdict).toBe(verdict) + expect(dispatch).not.toHaveBeenCalled() + } + ) +}) + describe('a compaction or rewind an earlier child left prepared', () => { async function leftPrepared(prepare: (fence: number) => Promise): Promise { await host.close(SESSION) @@ -668,7 +833,7 @@ describe('a compaction or rewind an earlier child left prepared', () => { const id = await accept('after the compaction') - await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(store.getRecord(SESSION)?.conversationCommand).toMatchObject({ phase: 'committed', state: 'unknown' @@ -691,7 +856,7 @@ describe('a compaction or rewind an earlier child left prepared', () => { const id = await accept('after the rewind') - await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'completed' }) }) }) 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 8302b403033..7c12909e036 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 @@ -430,7 +430,10 @@ describe('structured session acquisition options', () => { }) ).resolves.toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } + refusal: { + code: 'agent_session_operation_invalid', + message: "Codex couldn't restart. Send your message to try again." + } }) expect(releaseAcquisition).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() @@ -521,12 +524,16 @@ describe('structured session acquisition options', () => { onAttached: () => {} }) - // A proven exit before the journal opens is answered once, as the refusal its replay gives. + // A proven exit before the journal opens is answered once, as the refusal its replay gives; + // no exit was observed, so it names no situation. const failed = perform(store, CREATE_OPERATION, null) await (exitProven && failurePoint !== 'journal' ? expect(failed).resolves.toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: injected.message } + refusal: { + code: 'agent_session_operation_invalid', + message: "Codex couldn't restart. Send your message to try again." + } }) : expect(failed).rejects.toThrow( exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 566e41a6635..bb003f93427 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,3 +1,4 @@ +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionAccountHome, @@ -61,8 +62,8 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) - rewindSupport: NonNullable = (sessionId) => - this.liveOwnerOrNull(sessionId)?.rewindSupport?.(sessionId) ?? { + rewindSupport: NonNullable = (sessionId, agent) => + this.capabilityOwner(sessionId, agent)?.rewindSupport?.(sessionId) ?? { supported: false, reason: 'unsupported' } @@ -94,11 +95,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return change(input) } - supportsThreadGoal = (sessionId: string): boolean => - this.liveOwnerOrNull(sessionId)?.supportsThreadGoal?.(sessionId) ?? false + supportsThreadGoal = (sessionId: string, agent?: string): boolean => + this.capabilityOwner(sessionId, agent)?.supportsThreadGoal?.(sessionId) ?? false - recordsContextUsage = (sessionId: string): boolean => - this.liveOwnerOrNull(sessionId)?.recordsContextUsage?.(sessionId) ?? false + recordsContextUsage = (sessionId: string, agent?: string): boolean => + this.capabilityOwner(sessionId, agent)?.recordsContextUsage?.(sessionId) ?? false stopBackgroundTasks: NonNullable = ( input @@ -122,7 +123,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi awaitOptionWritable = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() - awaitStarted = (sessionId: string): Promise => + awaitStarted = (sessionId: string): Promise => this.liveOwnerOrNull(sessionId)?.awaitStarted?.(sessionId) ?? Promise.resolve() readOptions = (input: { sessionId: string; fence: number }) => { @@ -216,6 +217,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return adapter } + /** The live owner, or for a session at rest the provider it would start under. */ + private capabilityOwner(sessionId: string, agent?: string): StructuredAgentSessionAdapter | null { + return this.liveOwnerOrNull(sessionId) ?? (agent ? this.adapterForAgent(agent) : null) + } + private liveOwnerOrNull(sessionId: string): StructuredAgentSessionAdapter | null { const route = this.routes.get(sessionId) return route?.state === 'live' ? route.adapter : null 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 7a293193375..bc3c27ca5b1 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 @@ -27,23 +27,37 @@ import type { AgentSessionBackgroundTaskState, AgentSessionOptionsResult, AgentSessionSlashCommand, - AgentSessionThreadGoalChange, - AgentSessionWireRefusalCode + AgentSessionThreadGoalChange } from '../../../shared/agent-session-wire' -import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' +import { + isAgentSessionWireRefusalCode, + type AgentSessionRefusalReason +} from '../../../shared/agent-session-wire-refusals' +import type { 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 { + readonly code = 'agent_session_operation_invalid' + constructor( message: string, - readonly code: AgentSessionWireRefusalCode = 'agent_session_operation_invalid' + /** The situation, so the chat can say what to do; the message is Orca's log wording. Absent, + * the provider refused its own start. */ + readonly reason: AgentSessionRefusalReason<'agent_session_operation_invalid'> = 'providerStartFailed' ) { super(message) this.name = 'AgentSessionAcquisitionRefusal' } + + /** The conversation's history is more than this host can restore. */ + static historyTooLarge(message: string): AgentSessionAcquisitionRefusal { + return new AgentSessionAcquisitionRefusal(message, 'historyTooLarge') + } } export class AgentSessionPromptUnavailableError extends Error { @@ -103,11 +117,24 @@ export type AgentSessionAcquisition = { providerChildPhase?: StructuredAgentSessionProviderChildPhase } +/** A refusal before spawn that a person can act on; the site that refused names it. */ +export type AgentSessionPreSpawnReason = Extract< + AgentSessionRefusalReason<'agent_session_operation_invalid'>, + 'managedAccountEnvOverride' | 'accountSwitchInProgress' | 'managedAccountUnsupported' +> + /** Acquisition failed with first-hand proof that no provider process existed. */ export class AgentSessionPreSpawnError extends Error { - constructor(cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause), { cause }) + /** Absent: Orca's own reason, which only the log reads. A wrapped pre-spawn error keeps its. */ + readonly reason: AgentSessionPreSpawnReason | undefined + + constructor( + cause: unknown, + options: { reason?: AgentSessionPreSpawnReason; message?: string } = {} + ) { + super(options.message ?? (cause instanceof Error ? cause.message : String(cause)), { cause }) this.name = 'AgentSessionPreSpawnError' + this.reason = options.reason ?? (isAgentSessionPreSpawnError(cause) ? cause.reason : undefined) } } @@ -125,14 +152,19 @@ export type AgentSessionDispatchOutcome = * anything and never promotes this to `unknown`. */ | { state: 'admitted' } - | { state: 'rejected'; reason: string } + /** Words from `agentSessionFailureWords`, never written by hand. */ + | ({ state: 'rejected' } & AgentJournalDispatchRejection) /** The call did not settle. Never re-send on the user's behalf. */ | { state: 'unknown'; reason: string } export type StructuredAgentSessionEndedEvent = { type: 'ended' sessionId: string + /** Log text only; the chat's words come from `failure`. */ reason: string + /** Why it ended, as the adapter knows it: the provider's exit with its own diagnostic, or an + * Orca fault. Absent reads as a provider exit with nothing to add. */ + failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' fence: number acquisitionGeneration: string @@ -209,7 +241,8 @@ export type StructuredAgentSessionAdapter = { /** Revalidate after preparation, immediately before writing to the provider. */ beforeDispatch?: () => Promise }): Promise - rewindSupport?(sessionId: string): AgentSessionRewindSupport + /** `agent` answers for a session with no child running, from the provider alone. */ + rewindSupport?(sessionId: string, agent?: string): AgentSessionRewindSupport recoverRewind?(input: { sessionId: string fence: number @@ -234,8 +267,8 @@ export type StructuredAgentSessionAdapter = { turnId: string sessionId: string fence: number - onLateResult?: (result: { error?: string }) => Promise - }): Promise<{ error?: string }> + onLateResult?: (result: StructuredSessionCompactionResult) => Promise + }): Promise /** Cancels one turn, not the session: a session-wide interrupt would also kill * a turn the client never asked to stop. */ cancelTurn(input: { @@ -260,10 +293,10 @@ export type StructuredAgentSessionAdapter = { * start a new goal rather than rewrite that one's objective in place. */ replacesGoal: boolean }): Promise<{ ok: true } | { ok: false; rejected: string }> - /** Whether this live session can change its goal. */ - supportsThreadGoal?(sessionId: string): boolean - /** Whether this live session writes context facts to its turn rows. */ - recordsContextUsage?(sessionId: string): boolean + /** Whether this session can change its goal; `agent` answers one at rest. */ + supportsThreadGoal?(sessionId: string, agent?: string): boolean + /** Whether this session writes context facts to its turn rows; `agent` answers one at rest. */ + recordsContextUsage?(sessionId: string, agent?: string): boolean stopBackgroundTasks?(input: { sessionId: string fence: number @@ -292,7 +325,7 @@ export type StructuredAgentSessionAdapter = { /** Resolves once a session published before it proved its start has proven it, failed, or been * closed; at once for any other. A start that did not land resolves with the chat's words for * why. Never rejects. */ - awaitStarted?(sessionId: string): Promise + awaitStarted?(sessionId: string): Promise readOptions?(input: { sessionId: string; fence: number }): Promise /** Option keys skipped after a provider rejected their persisted restore value. */ readOptionRestoreFailures?(sessionId: string): readonly string[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index 7a85de6a473..3eccc4f9457 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -1,4 +1,9 @@ -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { + agentSessionRefusalError, + isAgentSessionRefusalError, + refuse, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams, AttachedJournal } from './structured-agent-session-attach' import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement' @@ -18,10 +23,13 @@ export async function prepareAdoptedTranscript( } catch (error) { return { ok: false, - refusal: { - code: 'agent_session_identity_required', - message: error instanceof Error ? error.message : String(error) - } + refusal: isAgentSessionRefusalError(error) + ? error.refusal + : refuse( + 'agent_session_identity_required', + { reason: 'transcriptUnreadable' }, + error instanceof Error ? error.message : String(error) + ) } } } @@ -35,7 +43,9 @@ async function readAdoptedTranscript( return null } if (!adopt.transcriptPath) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } const prepared = await prepareLegacyTranscriptImport({ agent: params.agent, @@ -49,7 +59,9 @@ async function readAdoptedTranscript( throw new Error(prepared.error) } if (prepared.items.length === 0) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) } return prepared.items } @@ -81,7 +93,9 @@ async function applyAdoptedTranscript( return } if (!adopt.transcriptPath) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } const imported = await importLegacyTranscriptIntoJournal({ journal: attached.journal, @@ -99,6 +113,8 @@ async function applyAdoptedTranscript( // `replaced: false` means the transcript decoded to nothing. The row promised a conversation and // the provider resumed one, so an empty journal here is a disagreement, not an empty chat. if (!imported.replaced) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptUnreadable' + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts new file mode 100644 index 00000000000..a4c93fa00f8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -0,0 +1,212 @@ +// Giving a session its provider child back. +// +// Only work starts one: the delivery loop for a queued message, and the few operations that need +// the provider itself — never a view, and never the app launching. It runs inside the session's +// serialize, with the attach it is given, so the eligibility it reads is the one the attach acts +// on. + +import { + providerDiagnosticOf, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' +import { + agentSessionRefusalFromReference, + readAgentSessionRefusalReference, + refuse, + refuseUnclassified, + type AgentSessionRefusalDetailsByCode, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire-refusals' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' +import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { + structuredAgentSessionResumeOperationId, + structuredAgentSessionResumeParams +} from './structured-agent-session-resume-eligibility' + +/** A resume answers with the attach's own refusal, verdict and all, so the asker can tell a lease + * someone else is settling from an owner that will not come back. */ +export type StructuredAgentSessionResumeOutcome = + | { ok: true } + | { + ok: false + refusal: AgentSessionWireRefusal + /** What the provider said about the failed start, for the chat's own record; host-side + * only, never on the refusal. */ + diagnostic?: ProviderDiagnostic + } + +/** The attach's caller key: the ledger row a start settles is Orca's own. */ +const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' + +/** + * Gives the session a provider child if it has none, for a caller inside its serialize — which is + * what makes "if it has none" exact: two askers run this in turn, and the second finds the first + * one's child. A failed start leaves the next asker to make its own. + */ +export async function ensureStructuredAgentSessionAgent( + context: StructuredAgentSessionAttachContext, + sessionId: string, + startedFor?: string +): Promise { + if (context.sessions.get(sessionId)?.child) { + return { ok: true } + } + const started = await startStructuredAgentSessionAgent(context, sessionId, startedFor) + if (!started.ok || context.sessions.get(sessionId)?.child) { + return started + } + return refuseResume( + 'agent_session_ownership_unknown', + { reason: 'noProviderChild' }, + 'The session attached without a provider child to write to.' + ) +} + +/** The same, for an operation's admission: a start that throws is that operation's refusal. */ +export function ensureStructuredAgentSessionAgentForOperation( + context: StructuredAgentSessionAttachContext, + sessionId: string +): Promise { + return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => { + // The error is Orca's own and goes to the log; the refusal says only that the start failed. + console.warn('[agent-session] starting the agent for an operation failed:', error) + return { + ok: false, + refusal: refuseUnclassified( + 'agent_session_owner_restart_failed', + agentSessionWriteNoticeEnglish(['restartFailed']) + ) + } + }) +} + +async function startStructuredAgentSessionAgent( + context: StructuredAgentSessionAttachContext, + sessionId: string, + startedFor: string | undefined +): Promise { + const callerKey = AGENT_START_CALLER_KEY + // The record is read only once this host has adjudicated it and recovery resolution has + // concluded about any owner a failed attempt left in `recovering`, so the eligibility below sees + // the lease the resolver handed back. + const unreconciled = await context.reconcileLeases(sessionId) + if (unreconciled) { + return { ok: false, refusal: unreconciled } + } + await context.runtimeState.resolveRecovery(sessionId) + const record = context.deps.store.getRecord(sessionId) + if (!record) { + return refuseResume( + 'agent_session_identity_required', + { reason: 'recordMissing' }, + 'No structured session exists by that id.' + ) + } + if (!adapterSupportsRecord(context.deps.adapter, record)) { + return refuseResume( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot resume the requested structured agent session.' + ) + } + const params = structuredAgentSessionResumeParams( + record, + structuredAgentSessionResumeOperationId(context.now()) + ) + if (!params) { + return record.lease.unreconciled + ? refuseResume( + 'execution_owner_reconciling', + { reason: 'hostReconciling' }, + 'This host has not yet adjudicated the session lease.' + ) + : record.lease.claimStatus === 'conflicted' + ? refuseResume( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + terminalOwnerRefusalMessage(record.lease) + ) + : refuseResume( + 'agent_session_ownership_unknown', + { reason: 'notResumable' }, + 'The session lease is not one this host may resume.' + ) + } + let attached: AgentSessionMutationResult + let acquisitionError: unknown + try { + attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { + ...(startedFor === undefined ? {} : { startedFor }), + onAcquisitionFailed: (error) => { + acquisitionError = error + } + }) + } catch (error) { + // The attach settles an acquisition that failed — the ledger row, the released lease — before + // it rethrows the cause. That row is the answer: a failure it recorded is this resume's + // refusal, verdict and all. Only an error it did not record is a fault for the caller. + const settled = settledResumeRefusal( + context, + callerKey, + params.envelope.clientOperationId, + sessionId, + error + ) + if (settled) { + return withDiagnostic(settled.refusal, error) + } + throw error + } + return attached.ok ? { ok: true } : withDiagnostic(attached.refusal, acquisitionError) +} + +function withDiagnostic( + refusal: AgentSessionWireRefusal, + error: unknown +): StructuredAgentSessionResumeOutcome { + const diagnostic = providerDiagnosticOf(error) + return { ok: false, refusal, ...(diagnostic ? { diagnostic } : {}) } +} + +function settledResumeRefusal( + context: Pick, + callerKey: string, + operationId: string, + sessionId: string, + error: unknown +): { ok: false; refusal: AgentSessionWireRefusal } | null { + const outcome = context.deps.store.getOperationRow(callerKey, operationId)?.outcome + const reference = + outcome?.status === 'failed' + ? readAgentSessionRefusalReference({ code: outcome.code, details: outcome.details }) + : undefined + if (outcome?.status !== 'failed' || !reference) { + return null + } + return failedCreateRefusal( + agentSessionRefusalFromReference( + reference, + outcome.message ?? (error instanceof Error ? error.message : String(error)) + ), + outcome.status, + context.deps.store.getRecord(sessionId) + ) +} + +function refuseResume( + code: C, + details: NoInfer, + message: string +): StructuredAgentSessionResumeOutcome { + return { ok: false, refusal: refuse(code, details, message) } +} 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 eef9297e82a..55128a23c9c 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 @@ -93,6 +93,7 @@ function exitBeforeProof(): Promise { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: `generation-${generation}`, reason: EXIT_REASON, + failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, cause: 'unexpected-exit', startupUnproven: true }) @@ -166,7 +167,7 @@ describe('an open chat receives every row its journal commits', () => { ) // One row, however many of its writers reported the start. expect(pane.received().statusRows).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) + 'Codex stopped before it finished starting. Send your message to try again.' ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index d6e5e9097a2..90c638c23fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -39,9 +39,7 @@ export type StructuredAgentSessionAttachContext = { reconcileLeases: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - /** Paired with `sessions.delete` by `forgetStructuredAgentSession`, which a close runs. */ - forgetStatus: (sessionId: string) => void - publishStatus?: (sessionId: string) => void + publishStatus: (sessionId: string) => void /** The conversation's one open journal, opened when closed; see `conversation-open`. */ openConversation: ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index 6af8eb369d5..16af00e32ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -35,6 +35,7 @@ export async function settlePostAcquisitionAttachFailure( outcome: { status: 'failed', code: 'agent_session_operation_invalid', + details: { reason: 'attachFailed' }, message: cause instanceof Error ? cause.message : String(cause) }, exitProof, 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 2969cb62f09..8fdee5c62bd 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 @@ -1,7 +1,9 @@ +import { refuse } from '../../../shared/agent-session-wire-refusals' import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure' import { failedAcquisitionRefusal, - failedAcquisitionSettlement + failedAcquisitionSettlement, + preSpawnFailureInWords } from './structured-agent-session-failed-create-refusal' import type { StructuredAgentSessionAdapter, @@ -72,6 +74,9 @@ export type AttachFlowInput = { /** A failure after acquisition released the session's acquisition; `cause` is that failure and * `rootGone` whether the release saw the provider root go. */ onAcquisitionReleased?: (cause: unknown, verdict: { rootGone: boolean }) => void + /** The error an acquisition failed with, for a host-side reader of the provider's words; the + * refusal never carries them. */ + onAcquisitionFailed?: (error: unknown) => void } export async function performAttach( @@ -80,10 +85,11 @@ export async function performAttach( const { params, store } = input const unsupported = (): AgentSessionMutationResult => ({ ok: false, - refusal: { - code: 'structured_agent_session_unsupported', - message: 'This execution host cannot create the requested structured agent session.' - } + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot create the requested structured agent session.' + ) }) const sessionId = params.envelope.sessionId const admitted = admitAttachOrRefuse(params) @@ -172,6 +178,10 @@ export async function performAttach( acquiredOwner = true } } catch (error) { + const wording = { + record: reservedRecord ?? store.getRecord(sessionId), + newSession: !params.providerHandle + } const spawnToken = reservedRecord?.lease.reservedSpawnToken if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) { // Settle processless proof and failed operation atomically. @@ -182,7 +192,7 @@ export async function performAttach( spawnToken, callerKey: input.callerKey, operationId: params.envelope.clientOperationId, - ...failedAcquisitionSettlement(error), + ...failedAcquisitionSettlement(error, wording), now: input.now() }) } catch (settlementError) { @@ -192,11 +202,18 @@ export async function performAttach( ) } } + input.onAcquisitionFailed?.(error) + const failed = failedAcquisitionRefusal(error, wording) + const thrown = failed ? error : preSpawnFailureInWords(error, wording) + if (failed || thrown !== error) { + // The answer carries only its sentence, so what failed is kept here. + console.warn('[agent-session] provider start failed:', error) + } return ( - failedAcquisitionRefusal(error) ?? { + failed ?? { ok: false, refusal: classifyStoreFailure( - error, + thrown, store.getRecord(sessionId)?.lease.runtimeFence ?? null, store.getRecord(sessionId) ) @@ -282,6 +299,7 @@ async function settleUnsupportedReservation( outcome: { status: 'failed', code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' }, message: 'Structured session support changed before the provider could start.' }, exitProof: 'processless', 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 591c09d8e32..40804bf55e2 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,3 +1,4 @@ +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. @@ -14,7 +15,7 @@ import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' +import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { stampFailedCreateOwnerVerdict } from './structured-agent-session-failed-create-refusal' import { pinnedAgentSessionLaunchArgs, @@ -22,6 +23,7 @@ import { } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionProviderChild, @@ -42,17 +44,18 @@ import { } from '../../observability/agent-session-instrumentation' export type StructuredAgentSessionAttachOptions = { - /** Provider-exit recovery: refuses once the ticket the restart was issued for is stale. */ - admitRecoveryTicket?: () => boolean recordPhase?: AgentSessionCreatePhaseRecorder + onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] + /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ + startedFor?: string } /** * The attach itself, for a caller already inside the session's serialize. * - * That is every caller that has to know what the session looks like RIGHT NOW: a hold, a send - * making sure it has an owner, provider-exit recovery. They run their - * check and this attach in one serialized step, so "the session has no child" is still true when + * That is every caller that has to know what the session looks like RIGHT NOW: the delivery loop, + * and an operation that needs the provider. They run their check and this attach in one serialized + * step, so "the session has no child" is still true when * the attach starts. `attachStructuredAgentSession` is this under `serialize`, for a client. */ export function attachStructuredAgentSessionUnderSerialize( @@ -105,12 +108,6 @@ async function runAttach( const fenceBefore = context.sessions.has(sessionId) ? structuredAgentSessionConversationFence(context.deps.store, sessionId) : null - if (options.admitRecoveryTicket && !options.admitRecoveryTicket()) { - return refuseAgentSessionMutation({ - code: 'agent_session_checkpoint_stale', - message: 'The provider-exit recovery ticket is no longer current.' - }) - } const unreconciled = await withAgentSessionCreatePhase('reconcile_leases', recordPhase, () => context.reconcileLeases(sessionId) ) @@ -129,7 +126,8 @@ async function runAttach( const attemptSink = context.runtimeState.mintEventSink(sessionId) // Read before the reserve clears it: how the previous generation ended decides how whatever it // left running is settled. - const priorDeathEvidence = context.deps.store.getRecord(sessionId)?.lease.deathEvidence ?? null + const priorRecord = context.deps.store.getRecord(sessionId) + const priorDeathEvidence = priorRecord?.lease.deathEvidence ?? null const attempt: { candidate: AttachCandidate | null; committed: boolean } = { candidate: null, committed: false @@ -159,6 +157,7 @@ async function runAttach( params, now: () => context.now(), recordPhase, + ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { acquisition: true @@ -174,6 +173,7 @@ async function runAttach( onAttached: async (attached, acquisitionGeneration, acquiredOwner, providerChildPhase) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null + const startedFor = acquiredOwner ? options.startedFor : current?.startedFor // A re-attach to a live child keeps the sink that child already writes through. const eventSink = acquiredOwner ? attemptSink @@ -185,7 +185,8 @@ async function runAttach( sessionId, fence, acquisitionGeneration, - deathEvidence: priorDeathEvidence + deathEvidence: priorDeathEvidence, + failureTextContext: structuredAgentSessionFailureWordsContext(priorRecord) }) } await bindAndDrain(eventSink, attached.journal, fence, (activity) => @@ -196,8 +197,9 @@ async function runAttach( child: { generation: acquisitionGeneration ?? current?.generation ?? null, fence, - // A re-attach to a live child keeps what that child already proved. - phase: acquiredOwner ? providerChildPhase : (current?.phase ?? 'ready') + // A re-attach to a live child keeps what that child already proved, and its cause. + phase: acquiredOwner ? providerChildPhase : (current?.phase ?? 'ready'), + ...(startedFor === undefined ? {} : { startedFor }) } } await recoverStructuredRewind( @@ -253,6 +255,8 @@ function endReleasedChild( fence: child.fence, cause: 'attach-failed', reason: cause instanceof Error ? cause.message : String(cause), + // Orca failed to attach; the provider said nothing. + failure: agentSessionFailureFact('hostFault'), duringStartup: child.phase === 'starting', ...verdict }) 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 4e6390c46d9..36f6ebc79e8 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 @@ -113,7 +113,7 @@ describe('attachJournal restart reconciliation', () => { expect(attached.unconfirmedClientMessageIds).toEqual([]) const submission = attached.journal.submissions()[0] expect(submission?.dispatchState).toBe('rejected') - expect(submission?.reason).toBe('not_delivered') + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) // Deciding is not sending: nothing here puts the message back on the wire. expect(dispatch).not.toHaveBeenCalled() }) 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 4cf6505d276..10c4866cea3 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 @@ -24,10 +24,14 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { - AGENT_SESSION_WIRE_REFUSAL_CODES, + AgentSessionRefusalError, + agentSessionRefusalFromReference, + agentSessionRefusalReference, + isAgentSessionWireRefusalCode, + refuse, type AgentSessionMutationEnvelope, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + type AgentSessionRefusalReference, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionFingerprintConflict, @@ -118,10 +122,11 @@ export function admitAttachOrRefuse( if (params.providerHandle && params.providerHandle.kind !== params.provider) { return { ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: `A ${params.provider} session requires a ${params.provider} provider handle.` - } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + `A ${params.provider} session requires a ${params.provider} provider handle.` + ) } } const fingerprint = computeAgentSessionPayloadFingerprint({ @@ -331,16 +336,21 @@ export function classifyStoreFailure( record: AgentSessionRecord | null = null ): AgentSessionWireRefusal { const rawCode = error instanceof Error ? error.message : String(error) - if (!(AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(rawCode)) { + if (!isAgentSessionWireRefusalCode(rawCode)) { throw error } - const code = rawCode as AgentSessionWireRefusalCode - return { - code, - // Why: a latched session is exactly where a bare store code strands the user. - message: - structuredAgentSessionRefusalMessage(code, record) ?? - `The session store refused this call: ${code}.`, - ...(code === 'agent_session_checkpoint_stale' && currentFence !== null ? { currentFence } : {}) - } + // A refusal error's message is its code, so its details are this code's. + const emitted: AgentSessionRefusalReference = + error instanceof AgentSessionRefusalError + ? agentSessionRefusalReference(error.refusal) + : { code: rawCode } + // Why: a latched session is exactly where a bare store code strands the user. + const told = structuredAgentSessionRefusalMessage(emitted, record) + const reference = told?.reference ?? emitted + return agentSessionRefusalFromReference( + reference.code === 'agent_session_checkpoint_stale' && currentFence !== null + ? { code: reference.code, details: { ...reference.details, currentFence } } + : reference, + told?.message ?? `The session store refused this call: ${rawCode}.` + ) } 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 8594b6d58ba..a88949a296f 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 @@ -19,15 +19,18 @@ export class StructuredAgentSessionBackgroundTaskChannel { private readonly deps: StructuredAgentSessionHostDeps, private readonly sessions: Map, private readonly subscribers: AgentSessionSubscribers, - private readonly requireSession: (sessionId: string) => StructuredAgentSessionHostSession, + /** The host's accessor: opens a conversation at rest, and never starts an agent. */ + private readonly conversation: ( + sessionId: string + ) => Promise, /** Task edges change the status summary too; the feed's equality check * keeps a no-op re-projection from reaching subscribers. */ private readonly onPublished: (sessionId: string) => void ) {} - history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { + async history(request: AgentSessionHistoryRequest): Promise { const result = readStructuredAgentSessionHistoryResult({ - journal: this.requireSession(request.sessionId).journal, + journal: (await this.conversation(request.sessionId)).journal, record: this.deps.store.getRecord(request.sessionId), request }) @@ -43,8 +46,9 @@ export class StructuredAgentSessionBackgroundTaskChannel { } } - subscribe(input: AgentSessionSubscribeInput): () => void { - const session = this.requireSession(input.sessionId) + /** Resolves once the conversation is open and the subscriber holds its opening frame. */ + async subscribe(input: AgentSessionSubscribeInput): Promise<() => void> { + const session = await this.conversation(input.sessionId) const backgroundTasks = this.state(input.sessionId) return this.subscribers.open({ ...input, 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 new file mode 100644 index 00000000000..e8b8d0f0044 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -0,0 +1,196 @@ +// A follow-up Claude queued behind the running turn is dropped by Stop, so the chat must record it +// as withdrawn and stop reading as working — under the SessionStart hook Orca installs, whose frame +// proves the start before the turn's system/init says the CLI can cancel its queue. + +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 { 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' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +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' + +const CALLER = { callerKey: 'client-1' } +// As Claude Code 2.1.280 advertises them on a turn's system/init frame. +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let queued: string[] +let claude: ReturnType + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-queued-stop-')) + resetHostTestOperationIds() + queued = [] + claude = fakeClaude({ + replayUuid: null, + initProof: 'session-start', + // What the real CLI answers: cancel_queued cancels the queue, a plain interrupt keeps it. + routes: { + interrupt: (params) => + params?.cancelQueued + ? { still_queued: [], cancelled: queued.splice(0) } + : { still_queued: [...queued] } + } + }) + 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.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalRoot: 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 eventually(assertion: () => unknown): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope( + method: 'agentSession.send' | 'agentSession.cancel', + fields: Record +) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error('send refused') + } + return sent.value.clientMessageId +} + +async function dispatch(clientMessageId: string) { + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + return { state: submission?.dispatchState, reason: submission?.reason } +} + +async function status(): Promise { + const snapshot = await host.journalSnapshot(SESSION) + return projectStructuredAgentSessionStatus( + snapshot.items, + snapshot.submissions, + store.getRecord(SESSION)!.lease.runtimeFence + ) +} + +it('withdraws a follow-up Claude queued behind the running turn when that turn is stopped', async () => { + const connection = claude.connections[0]! + 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. + connection.handlers.onMessage?.({ + type: 'system', + subtype: 'init', + session_id: PROVIDER_SESSION_ID, + uuid: 'turn-init', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + connection.handlers.onMessage?.({ + ...connection.sent.at(-1)!, + uuid: connection.sent.at(-1)!.uuid + }) + await eventually(async () => expect((await dispatch(first)).state).toBe('accepted')) + const turnId = activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + expect(turnId).not.toBeNull() + + 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! + }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + // The interrupted turn closes as the CLI ends it. + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'error_during_execution', + session_id: PROVIDER_SESSION_ID, + uuid: 'interrupted-result' + }) + await eventually(async () => + expect(await dispatch(followUp)).toEqual({ + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + ) + await eventually(async () => expect(await status()).toBe('idle')) +}, 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 537a2ac695f..4a8f0a1440f 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 @@ -11,7 +11,7 @@ import { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { evictHeldStructuredAgentSession } from './structured-agent-session-host-lifetime' +import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -24,7 +24,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -describe('Claude root-exit eviction', () => { +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) @@ -78,6 +78,7 @@ describe('Claude root-exit eviction', () => { journalDir: join(root, 'journal') }) const close = vi.spyOn(journal, 'close') + const publishStatus = vi.fn() const params: AgentSessionAttachParams = { envelope: { sessionId: 'session-1', @@ -116,13 +117,13 @@ describe('Claude root-exit eviction', () => { claude.connections[0]!.handlers.onExit?.(new Error('provider exited')) await expect( - evictHeldStructuredAgentSession( + stopStructuredAgentSessionAgentUnderSerialize( { deps, runtimeState, sessions, now: () => NOW + 30 * 60_000, - forgetStatus: vi.fn() + publishStatus }, 'session-1' ) @@ -133,8 +134,10 @@ describe('Claude root-exit eviction', () => { ownerProcess: null, deathEvidence: { kind: 'exit-observed' } }) - expect(sessions.size).toBe(0) - expect(close).toHaveBeenCalledOnce() + // The agent went to rest; the conversation stays open and listed. + expect(sessions.get('session-1')?.child).toBeNull() + expect(close).not.toHaveBeenCalled() + expect(publishStatus).toHaveBeenCalledWith('session-1') // The adapter agrees the session is over: nothing is left to refuse the next start. await expect(adapter.closeSession('session-1')).resolves.toBe(true) }) 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 24ab5cad012..f5c7fb825f3 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 @@ -1,3 +1,4 @@ +import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -28,9 +29,15 @@ export class StructuredAgentSessionClientDelivery { private readonly sessions: Map, now: () => number, deps: () => StructuredAgentSessionHostDeps, - private readonly onJournalActivity?: (sessionId: string) => void + private readonly onJournalActivity?: (sessionId: string) => void, + onAgentStarted?: (sessionId: string) => void ) { - this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps }) + this.statusFeed = createStructuredAgentSessionHostStatusFeed({ + sessions, + now, + deps, + ...(onAgentStarted ? { onAgentStarted } : {}) + }) this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now, @@ -70,10 +77,16 @@ export class StructuredAgentSessionClientDelivery { subscriber: StructuredAgentSessionTurnCompletionSubscriber ): (() => void) => this.turnCompletionFeed.subscribe(subscriber) - closeSession(sessionId: string): void { + /** The conversation's handle closed. Its status row stays in every session list; the + * agent-status store keeps it too while the chat still has a tab to show it in. */ + closeSession(sessionId: string, options: { listed: boolean }): void { this.sendSettlement.closeSession(sessionId) - this.statusFeed.close(sessionId) - // The next attach re-baselines rather than announcing the turn it was already holding. + if (options.listed) { + this.statusFeed.revokeLive(sessionId) + } else { + this.statusFeed.close(sessionId) + } + // The next open re-baselines rather than announcing the turn it was already holding. this.turnCompletionFeed.forget(sessionId) } @@ -94,7 +107,7 @@ export class StructuredAgentSessionClientDelivery { private requireJournal(sessionId: string): AgentSessionJournal { const journal = this.sessions.get(sessionId)?.journal if (!journal) { - throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) } return 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 index 303ecbe92fc..f5410de74f6 100644 --- 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 @@ -135,7 +135,7 @@ function attachContext( reconcileLeases: async () => null, serialize: (_sessionId: string, task: () => Promise) => task(), now: () => 1, - forgetStatus: () => undefined + publishStatus: () => undefined } as unknown as StructuredAgentSessionAttachContext } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts new file mode 100644 index 00000000000..bf3cd1f96a6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -0,0 +1,326 @@ +// Stopping an agent and closing its conversation's handle are two things, and a reader, a session +// list and a send each see only the one that happened. Ticks are driven by hand. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' +import { + collectSubscriber, + createRestTestRig, + foundRestTestChat, + IDLE_MS, + readerSaw, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + restTestSend, + sweepOnce, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' +import { hostTestAttachParams } from './structured-agent-session-host-test-data' + +let rig: RestTestRig + +const COLD_START = { timeout: 10_000 } + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +function fence(): number { + return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 +} + +function openSession(): StructuredAgentSessionHostSession | undefined { + return rig.host.collaboratorsForTests().sessions.get(SESSION) +} + +function statusRows(): AgentSessionStatusSummary[] { + return rig.statusEvents + .flatMap((event) => (event.type === 'status' ? [event.session] : [])) + .filter((summary) => summary.sessionId === SESSION) +} + +describe('a stop that fails', () => { + it('is retried on the next tick (P2-11 a)', async () => { + await foundRestTestChat(rig) + rig.adapter.closeSession.mockResolvedValueOnce(false) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(openSession()?.owesProviderChildWindDown).toMatchObject({ + generation: expect.any(String) + }) + await sweepOnce(rig.host) + + expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() + }) + + it('finishes its wind-down on the next tick, whatever its own writes did to the clock (P2-11 b)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS + 1 + // The child is proven gone and its work settled, then handing the lease back fails. + const transition = vi + .spyOn(rig.store, 'transitionHandoff') + .mockRejectedValueOnce(new Error('store unavailable')) + + await sweepOnce(rig.host) + expect(transition).toHaveBeenCalledOnce() + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + // Five minutes later, not thirty. + rig.clock.now += 5 * 60_000 + await sweepOnce(rig.host) + + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + expect(rig.adapter.closeSession).toHaveBeenCalledOnce() + }) +}) + +describe('closing the handle', () => { + it('keeps a tabbed chat listed and its reader open, and a send reopens it for that reader (P2-14)', async () => { + await foundRestTestChat(rig) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.host.hasSession(SESSION)).toBe(false) + // The row stays in the agent-status store, idle and no longer running here, and a session + // list that connects now still gets it. + expect(rig.sink.forget).not.toHaveBeenCalled() + expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) + expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() + const late: { type: string; sessions?: AgentSessionStatusSummary[] }[] = [] + rig.host.subscribeStatus({ id: 'late', emit: (event) => late.push(event) }) + expect(late[0]?.sessions?.map((summary) => summary.sessionId)).toContain(SESSION) + expect(reader.events.some((event) => event.type === 'end')).toBe(false) + + const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) + expect(sent.ok).toBe(true) + // A cold start: reopen, reconcile and acquire. + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) + // The reader opened on the first handle is fed by the second. + await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('after the close')) + }) + + it('drops the agent-status row of a chat whose tab is gone (P2-14, retired)', async () => { + await foundRestTestChat(rig) + await rig.store.setSessionTabVisibility(SESSION, false) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(rig.sink.forget).toHaveBeenCalled() + }) + + it('forgets the row of a resting chat once its tab closes (P2-32)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS + 1 + await sweepOnce(rig.host) + expect(rig.sink.forget).not.toHaveBeenCalled() + + await rig.host.setSessionTabVisibility(SESSION, false) + expect(rig.sink.forget).toHaveBeenCalled() + }) + + it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => { + await foundRestTestChat(rig) + + await rig.host.close(SESSION) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + // The stop says not-running; the row belongs to the tab, so nothing forgets it. + expect(rig.sink.forget).not.toHaveBeenCalled() + expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) + expect(rig.sink.publish.mock.calls.at(-1)?.[0].hostExecutionOwned).toBeUndefined() + + await rig.host.setSessionTabVisibility(SESSION, false) + expect(rig.sink.forget).toHaveBeenCalledOnce() + }) + + it('never hands a reader a handle it is closing (P2-25)', async () => { + await foundRestTestChat(rig) + const journal = openSession()?.journal + if (!journal) { + throw new Error('the conversation should be open') + } + const closing = Promise.withResolvers() + const gate = Promise.withResolvers() + const close = journal.close.bind(journal) + vi.spyOn(journal, 'close').mockImplementation(async () => { + await close() + closing.resolve() + await gate.promise + }) + rig.clock.now += IDLE_MS + 1 + const tick = sweepOnce(rig.host) + await closing.promise + + const history = rig.host.history({ sessionId: SESSION, direction: 'tail' }) + const facts = readStructuredSessionGateFacts(SESSION) + gate.resolve() + await tick + + const page = await history + expect(page.ok && page.page.items.length).toBeGreaterThan(0) + await expect(facts).resolves.not.toBeNull() + // And the handle a write finds is a live one. + const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) + expect(sent.ok).toBe(true) + // A cold start: reopen, reconcile and acquire. + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) + }) + + it('never re-enters the session lock: a send and a Stop right after still finish (P2-28)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS + 1 + const tick = sweepOnce(rig.host) + const within = (work: Promise) => + Promise.race([ + work, + new Promise((_, reject) => setTimeout(() => reject(new Error('wedged')), 2_000)) + ]) + const sent = within(rig.host.send(CALLER, restTestSend('right after', fence()))) + + await within(tick) + expect(await sent).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + await expect(within(sweepOnce(rig.host))).resolves.toBeUndefined() + }) +}) + +describe('the sweep and the lease (P2-20)', () => { + it('reads only open conversations and never probes or resolves a lease', async () => { + await foundRestTestChat(rig) + const probeOwner = vi.fn(async () => ({ outcome: 'pid-absent' as const })) + await rig.restart({ probeOwner }) + setStructuredAgentSessionHost(rig.host) + await rig.store.transitionHandoff(SESSION, (current) => ({ + ...current, + lease: { ...current.lease, handoffStage: 'recovering' } + })) + const resolveRecovery = vi.spyOn( + rig.host.collaboratorsForTests().runtimeState, + 'resolveRecovery' + ) + // Open, at rest, on a lease still recovering. + await rig.host.journalSnapshot(SESSION) + expect(rig.host.hasSession(SESSION)).toBe(true) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(probeOwner).not.toHaveBeenCalled() + expect(resolveRecovery).not.toHaveBeenCalled() + }) +}) + +describe('a start that never finishes (P2-15)', () => { + it('is stopped by the sweep, and the delivery loop alone writes its one row and rejection', async () => { + const stopReason = 'Codex never finished starting, so Orca stopped it.' + const order: string[] = [] + const started = Promise.withResolvers() + rig.adapter.closeSession.mockImplementation(async () => { + order.push('stopped') + started.resolve() + return true + }) + const spawn = rig.adapter.acquire.getMockImplementation()! + rig.adapter.acquire.mockImplementationOnce(async (input) => ({ + ...(await spawn(input)), + providerChildPhase: 'starting' as const + })) + Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) + const reject = AgentSessionJournal.prototype.rejectQueuedSubmissions + vi.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions').mockImplementation(function ( + this: AgentSessionJournal, + ...args + ) { + // Not the open's sweep of an earlier process's leftovers. + if (args[1].rejection.kind !== 'hostRestarted') { + order.push(`rejected: ${args[1].reason}`) + } + return reject.apply(this, args) + }) + const reader = collectSubscriber() + const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + expect(attached.ok).toBe(true) + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) + expect(sent.ok).toBe(true) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + await vi.waitFor(() => + expect(readerSaw(reader.events).submissions).toContainEqual( + expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) + ) + ) + // One rejection, written after the stop by the loop, with the stop's own words. + expect(order).toEqual(['stopped', `rejected: ${stopReason}`]) + const errorRows = reader.events.flatMap((event) => + event.type === 'batch' + ? event.batch.items.filter( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ) + : [] + ) + expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ + stopReason + ]) + expect(rig.adapter.dispatch).not.toHaveBeenCalled() + + const again = await rig.host.send(CALLER, restTestSend('try again', fence())) + expect(again.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce(), COLD_START) + await expect(sweepOnce(rig.host)).resolves.toBeUndefined() + }) +}) + +describe('the wind-down retry with a message queued (P2-31)', () => { + it('waits for the delivery rather than rejecting a message accepted after the failed stop', async () => { + const queued = { clientMessageId: 'm', dispatchState: 'pending', handoverRecorded: true } + const session = { + journal: { + submissions: () => [queued], + pendingSubmissions: () => [queued], + snapshot: () => ({ items: [] }) + }, + child: null, + owesProviderChildWindDown: { generation: 'generation-1', fence: 1 } + } + const stopAgent = vi.fn(async () => undefined) + const sweep = new StructuredAgentSessionIdleSweep({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. + sessions: Object.assign(new Map([[SESSION, session as never]]), { + lastActivityAt: () => 0, + touch: () => undefined + }), + serialize: (_id, task) => task(), + now: () => IDLE_MS + 1, + isDisposed: () => false, + deliveryActive: () => true, + backgroundTaskState: () => undefined, + hasOpenDispatch: () => false, + stopAgent, + stopStartingAgent: stopAgent, + closeConversation: vi.fn(async () => false), + onError: (_id, error) => { + throw error + } + }) + await sweep.tick() + expect(stopAgent).not.toHaveBeenCalled() + }) +}) 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 new file mode 100644 index 00000000000..0fbd1a0a233 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -0,0 +1,148 @@ +// How a conversation is reached, closed and swept — the host's lifetime rules in one place. +// +// A conversation is reached only through `conversation`, which opens it at rest and starts no +// agent. It closes when its tab closes or the idle sweep finds its handle is only a cache. Every +// public entry point here takes the session's serialize once and calls the under-serialize forms, +// because the queue is not reentrant. + +import { + AgentSessionRefusalError, + agentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' +import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import { + abandonQueuedStructuredAgentSessionMessages, + closeStructuredAgentSessionConversationUnderSerialize, + stopStructuredAgentSessionAgentUnderSerialize, + type StructuredAgentSessionLifetimeContext +} from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' +import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' + +export type StructuredAgentSessionConversationLifetime = ReturnType< + typeof createStructuredAgentSessionConversationLifetime +> + +export function createStructuredAgentSessionConversationLifetime(host: { + /** Resolved per call: the host's collaborators are assigned after this is built. */ + context: () => StructuredAgentSessionLifetimeContext + sessions: StructuredAgentSessionConversations + serialize: (sessionId: string, task: () => Promise) => Promise + /** PR 1's one open function, for a caller inside the session's serialize. */ + open: (sessionId: string) => Promise + deliveryActive: (sessionId: string) => boolean + /** The handle closed: `listed` keeps the chat's row in the agent-status store for its tab. */ + closeStatus: (sessionId: string, options: { listed: boolean }) => void +}) { + let disposed = false + const { sessions, serialize } = host + const deps = () => host.context().deps + const readRefusals = createJournalOpenReadRefusals() + // 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) + + const closeConversation = (sessionId: string): Promise => + closeStructuredAgentSessionConversationUnderSerialize( + { + sessions, + closeStatus: (id) => { + const tabs = deps().store.getVisibleSessionTabIndex() + // A legacy store cannot say, so the row stays; restart is the boundary that forgets. + host.closeStatus(id, { listed: !tabs.present || tabs.sessionIds.includes(id) }) + } + }, + sessionId + ) + + const idleSweep = new StructuredAgentSessionIdleSweep({ + sessions, + serialize, + now: () => host.context().now(), + isDisposed: () => disposed, + deliveryActive: host.deliveryActive, + backgroundTaskState: (sessionId) => deps().adapter.backgroundTaskState?.(sessionId), + hasOpenDispatch: (sessionId) => { + const record = deps().store.getRecord(sessionId) + return record !== null && deps().hasOpenDispatch?.(record) === true + }, + stopAgent, + // A host stop: the delivery loop waiting on this child writes the one error row and rejects + // what is queued with it, both worded from the hostStopped fact. + stopStartingAgent: (sessionId) => + stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { + cause: 'host-stop' + }), + closeConversation, + onError: (sessionId, error) => deps().onEventSinkError?.({ sessionId, error }), + ...deps().idleSweep + }) + + return { + idleSweep, + stopAgent, + /** Quit has begun: nothing opens a conversation or sweeps one after this. */ + dispose: (): void => { + disposed = true + idleSweep.dispose() + }, + /** + * The only way any code reaches a session. An open conversation answers without the lock, so + * a read never waits behind a start; a closed one is opened once, under it. Nothing here + * touches the lease or starts a child. Use the result before the next `await`: a close can + * drop it after. + */ + conversation: async (sessionId: string): Promise => { + const open = sessions.get(sessionId) + if (open) { + readRefusals.forget(sessionId) + return open + } + const record = deps().store.getRecord(sessionId) + if (!record) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + } + if (!adapterSupportsRecord(deps().adapter, record)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + return serialize(sessionId, async () => { + // Read at the open itself: a read queued before quit began runs after it. + if (disposed) { + throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED) + } + const session = await host.open(sessionId).catch((error: unknown) => { + throw readRefusals.refusal(sessionId, error) + }) + if (!session) { + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'recordMissing' + }) + } + readRefusals.forget(sessionId) + return session + }) + }, + /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is + * still queued will not be sent. */ + close: (sessionId: string): Promise => + serialize(sessionId, async () => { + readRefusals.forget(sessionId) + const session = sessions.get(sessionId) + if (session) { + // Abandoned before the stop, so no start delivers it. + await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal) + } + await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { + cause: 'evict' + }) + await closeConversation(sessionId) + }) + } +} 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 07d1fb6b5ff..bce79ca71f8 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 @@ -21,6 +21,7 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -112,7 +113,8 @@ export async function openStructuredAgentSessionConversationJournal( sessionId, fence, acquisitionGeneration: null, - deathEvidence: record.lease.deathEvidence ?? null + deathEvidence: record.lease.deathEvidence ?? null, + failureTextContext: structuredAgentSessionFailureWordsContext(record) }) } } catch (error) { 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 7014e394728..b294540990d 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 @@ -54,7 +54,8 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn() + onDeliveryError: vi.fn(), + now: () => 0 }) const journal = await openJournal('a') conversations.set('session-1', session(journal)) @@ -70,7 +71,11 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() // Collaborators hold the host's map as a plain `Map`; `set` still reaches the binding. const sessions: Map = - new StructuredAgentSessionConversations({ deliver, onDeliveryError: vi.fn() }) + new StructuredAgentSessionConversations({ + deliver, + onDeliveryError: vi.fn(), + now: () => 0 + }) const journal = await openJournal('a') sessions.set('session-1', session(journal)) @@ -83,7 +88,8 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn() + onDeliveryError: vi.fn(), + now: () => 0 }) const journal = await openJournal('a') conversations.set('session-1', session(journal)) @@ -97,7 +103,8 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn() + onDeliveryError: vi.fn(), + now: () => 0 }) const replaced = await openJournal('a') const current = await openJournal('b') @@ -115,7 +122,8 @@ describe('a conversation delivers what its journal commits', () => { const deliver = vi.fn() const conversations = new StructuredAgentSessionConversations({ deliver, - onDeliveryError: vi.fn() + onDeliveryError: vi.fn(), + now: () => 0 }) const journal = await openJournal('a') conversations.set('session-1', session(journal)) @@ -133,7 +141,8 @@ describe('a conversation delivers what its journal commits', () => { deliver: () => { throw failure }, - onDeliveryError + onDeliveryError, + now: () => 0 }) const journal = await openJournal('a') conversations.set('session-1', session(journal)) 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 2f38ddf4da2..6fcaef07483 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 @@ -8,15 +8,21 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi * * Delivery runs a microtask after the commit, so a reader that throws cannot fail a write that is * already durable. A handle this map has since replaced or dropped delivers nothing. + * + * Each entry also carries when it last saw activity — its open, then every journal publish — which + * is the idle sweep's clock. In memory only, so it dies with the entry. */ export class StructuredAgentSessionConversations extends Map< string, StructuredAgentSessionHostSession > { + private readonly activity = new Map() + constructor( private readonly delivery: { deliver: (sessionId: string, journal: AgentSessionJournal) => void onDeliveryError: (sessionId: string, error: unknown) => void + now: () => number } ) { super() @@ -42,6 +48,22 @@ export class StructuredAgentSessionConversations extends Map< } }) }) + this.activity.set(sessionId, this.delivery.now()) return super.set(sessionId, session) } + + override delete(sessionId: string): boolean { + this.activity.delete(sessionId) + return super.delete(sessionId) + } + + touch(sessionId: string): void { + if (this.has(sessionId)) { + this.activity.set(sessionId, this.delivery.now()) + } + } + + lastActivityAt(sessionId: string): number | undefined { + return this.activity.get(sessionId) + } } 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 09a5aee05b7..6e364ccb477 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 @@ -146,7 +146,9 @@ describe('a host that dies while its Codex child is starting', () => { ownerProcess: null, deathEvidence: { kind: 'pid-absent' } }) - await relaunched.hold(SESSION, 'desktop-chat:1') + // What the next send's delivery does: start at the record's current fence. + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await relaunched.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) expect(restarted.connections).toHaveLength(1) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', @@ -156,7 +158,7 @@ describe('a host that dies while its Codex child is starting', () => { }) // The client keeps a create it never heard back from and retries it under the same operation id, so -// that replay, not a fresh hold, is what the user's Retry and first send go through. +// that replay, not a fresh start, is what the user's Retry and first send go through. describe('a create replayed after the host that ran it died', () => { const PAST_OPERATION_EXPIRY = AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 60_000 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 8ad51871c24..4a0febb6ceb 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 @@ -4,16 +4,21 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { dispatchRejectionReasonIsInternal } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + agentSessionFailureFact, + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic +} from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { captureUnfinishedStructuredAgentSessionWork, - MAX_UNEXPECTED_EXIT_REASON_CHARS, settleStructuredAgentSessionDeadGeneration, - UNEXPECTED_PROVIDER_EXIT_OUTCOME, unfinishedStructuredAgentSessionWorkWasInterrupted } from './structured-agent-session-dead-generation-settlement' +const UNEXPECTED_PROVIDER_EXIT_OUTCOME = + 'The agent stopped while this response was in progress. You can continue in this conversation.' + const SESSION = 'session-dead-generation' const THREAD = 'thread-1' let root: string @@ -143,7 +148,7 @@ describe('dead structured-session generation settlement', () => { ).toHaveLength(1) }) - it('keeps the actionable tail when the provider dumps a stderr wall into its exit reason', async () => { + it('keeps a stderr wall out of the sentence, as a bounded detail for a log', async () => { await seedUnfinishedWork() await expect( @@ -155,19 +160,47 @@ describe('dead structured-session generation settlement', () => { pendingSubmissionReason: 'provider_exited_before_acknowledgement', verdict: { state: 'interrupted', completedAt: 1_000 }, showUnexpectedExitOutcome: true, - unexpectedExitReason: 'stack frame '.repeat(4_000) + exitFailure: agentSessionFailureFact('providerExited', { + detail: providerDiagnostic('stack frame '.repeat(4_000), 'log') + }) }) ).resolves.toBe(true) const statuses = journal .snapshot() - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) expect(statuses).toHaveLength(1) - // The cause is bounded before composing, so the row never reaches the byte cap that would - // truncate the sentence telling the user the conversation is still usable. - expect(statuses[0]).toContain('stack frame') - expect(statuses[0]).toMatch(/You can continue in this conversation\.$/) - expect(statuses[0]?.length).toBeLessThan(MAX_UNEXPECTED_EXIT_REASON_CHARS * 2) + expect(statuses[0]?.text).toBe(UNEXPECTED_PROVIDER_EXIT_OUTCOME) + expect(statuses[0]?.failure?.kind).toBe('providerExited') + expect(statuses[0]?.failure?.detail?.audience).toBe('log') + expect(statuses[0]?.failure?.detail?.text.length).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS) + }) + + it("words Orca's own fault as Orca's, never as the provider stopping", async () => { + await seedUnfinishedWork() + + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:generation-1`, + pendingSubmissionReason: 'provider_exited_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + showUnexpectedExitOutcome: true, + // Orca stopped the provider because its own journal failed. + exitFailure: agentSessionFailureFact('hostFault') + }) + + const statuses = journal + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + expect(statuses).toEqual([ + { + kind: 'status', + text: "Orca ran into a problem, so this didn't go through. Try again.", + failure: { kind: 'hostFault' } + } + ]) }) it('retries an already settled expected close without writing through a closed journal gate', async () => { @@ -276,16 +309,24 @@ describe('dead structured-session generation settlement', () => { settlementId: `provider-exit:${SESSION}:7:generation-1`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', verdict: { state: 'interrupted', completedAt: 1_000 }, - unexpectedExitReason: 'claude stream-json exited (code 1): not signed in', + exitFailure: agentSessionFailureFact('providerExited', { + detail: providerDiagnostic('code 1\nnot signed in', 'log') + }), exitedDuringStartup: { generation: 'generation-1' } }) - const reason = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): not signed in.' + // The sentence is Orca's; the stderr the exit carried rides as a log detail only. expect(journal.submissions()).toEqual([ - expect.objectContaining({ clientMessageId: 'client-held', dispatchState: 'rejected', reason }) + expect.objectContaining({ + clientMessageId: 'client-held', + dispatchState: 'rejected', + reason: 'The agent stopped before it finished starting. Send your message to try again.', + rejection: { + kind: 'providerStartFailed', + detail: { text: 'code 1\nnot signed in', audience: 'log' } + } + }) ]) - expect(dispatchRejectionReasonIsInternal(reason)).toBe(false) }) it("keeps a subagent's settled rows the subagent's, in one batch and after a reopen", async () => { 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 9d20d93a82f..f6a166c7c02 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 @@ -1,4 +1,10 @@ +import { + agentSessionFailureFact, + MAX_PROVIDER_DIAGNOSTIC_CHARS, + type SubmissionRejectionFact +} 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 @@ -6,12 +12,17 @@ import type { import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { - boundJournalStatusText, - cancelledJournalPromptBody -} from '../agent-session-journal/journal-prompt-body-bounds' +import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { structuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { + hasStructuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRow +} from './structured-agent-session-start-failure-row' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { runningTurnLifecycleRevisions, @@ -19,67 +30,9 @@ import { type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' -export const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'The provider stopped while this response was in progress. You can continue in this conversation.' - -/** A provider may put a whole stderr dump in its exit reason; unbounded it would push the - * actionable tail past the row's byte cap and lose it to truncation. */ -export const MAX_UNEXPECTED_EXIT_REASON_CHARS = 512 - -/** The cause is the only thing separating an auth failure from an OOM kill, so it is carried - * into the copy rather than left in the durable record nothing renders. */ -export function unexpectedProviderExitOutcome(reason?: string): string { - const detail = exitReasonDetail(reason) - return detail - ? `The provider stopped while this response was in progress: ${detail}. You can continue in this conversation.` - : UNEXPECTED_PROVIDER_EXIT_OUTCOME -} - -/** A restart that produced no child, answered to the send that asked for it; its cause is the - * whole story, and nothing is remembered, so the next try is a fresh one. A new chat is offered - * only when the host holds nothing this chat could restart from. */ -export function ownerRestartFailedOutcome(input: { - agentName: string - reason?: string - resumable: boolean -}): string { - const detail = exitReasonDetail(input.reason) - const failed = detail - ? `${input.agentName} couldn't restart: ${detail}.` - : `${input.agentName} couldn't restart.` - return input.resumable ? failed : `${failed} Start a new chat to continue.` -} - -/** A start that never finished has no response to interrupt; its cause is the whole story. */ -export function providerStartupFailureOutcome(reason?: string): string { - const detail = exitReasonDetail(reason) - return detail - ? `The provider stopped before it finished starting: ${detail}.` - : 'The provider stopped before it finished starting.' -} - -/** Why a message accepted but not yet handed over was rejected when its child exited. */ -export function providerExitBeforeDeliveryRejection(reason?: string): string { - const detail = exitReasonDetail(reason) - return detail - ? `The provider stopped before this message was sent: ${detail}.` - : 'The provider stopped before this message was sent.' -} - -/** Why a send a child that never started left unwritten was rejected. The child's own diagnostic is - * the cause the user can act on, so it is the reason, in the words the chat row uses. */ -export function providerStartupFailureRejection(cause?: unknown): string { - return providerStartupFailureOutcome( - cause === undefined ? undefined : cause instanceof Error ? cause.message : String(cause) - ) -} - -function exitReasonDetail(reason: string | undefined): string | undefined { - return reason - ?.slice(0, MAX_UNEXPECTED_EXIT_REASON_CHARS) - .trim() - .replace(/[.\s]+$/, '') -} +/** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the + * same cap. */ +export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS type DeadGenerationSubmission = Pick< ReturnType[number], @@ -151,8 +104,10 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { verdict: StructuredAgentSessionTurnVerdict pendingSubmissionReason: string showUnexpectedExitOutcome?: boolean - /** Why the provider stopped, when the host has it. Rendered with the outcome copy. */ - unexpectedExitReason?: string + /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ + exitFailure?: SubmissionRejectionFact + /** Who a failed start's sentence names. */ + failureTextContext?: AgentSessionFailureWordsContext /** The provider never finished starting: the start that failed, keyed by the child's * generation. Its row is the one the delivery loop writes for the same start. */ exitedDuringStartup?: { generation: string | null } @@ -168,29 +123,38 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { // child that never proved its start accepted nothing either — input is written only after it // initializes — so every send it was handed is rejected with the child's own diagnostic. A // proven child's handed-over sends stay in doubt. - await (input.exitedDuringStartup - ? input.journal.rejectPendingSubmissions( - input.fence, - providerStartupFailureRejection(input.unexpectedExitReason) - ) + const startupFailure = input.exitedDuringStartup + ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) + : null + await (startupFailure + ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] - if (showUnexpectedExitOutcome && input.exitedDuringStartup) { - // Until views stop starting children, a start can die with nothing queued for the loop. - mutations.push( - structuredAgentSessionStartFailureRow( - input.exitedDuringStartup.generation ?? input.settlementId, - providerStartupFailureOutcome(input.unexpectedExitReason) - ) - ) + if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { + const startKey = input.exitedDuringStartup.generation ?? input.settlementId + // A start a message waited on is the delivery loop's to record, before or after this exit, + // in the words it rejected the message with; this row is for a command, goal or rewind start. + // A row already written stays: rejected is terminal, so its words are not reworded. + const recordedByDeliveryLoop = + input.journal.submissions?.().some(isQueuedAgentJournalSubmission) || + hasStructuredAgentSessionStartFailureRow(items, startKey) + if (!recordedByDeliveryLoop) { + mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) + } } else if (showUnexpectedExitOutcome) { mutations.push({ kind: 'item', identity: { provider: 'orca', clientMessageId: input.settlementId }, body: { kind: 'status', - text: boundJournalStatusText(unexpectedProviderExitOutcome(input.unexpectedExitReason)) + ...agentSessionFailureWords( + input.exitFailure ?? agentSessionFailureFact('providerExited'), + { + ...input.failureTextContext, + surface: 'row' + } + ) } }) } @@ -231,6 +195,8 @@ export async function settleStaleStructuredAgentSessionState(input: { fence: number acquisitionGeneration: string | null deathEvidence: AgentSessionDeathEvidence | null + /** Who the exit row names. */ + failureTextContext?: AgentSessionFailureWordsContext }): Promise { const { journal } = input const items = journal.snapshot().items @@ -250,9 +216,14 @@ export async function settleStaleStructuredAgentSessionState(input: { mutations.unshift({ kind: 'item', identity: { provider: 'orca', clientMessageId: settlementId }, + // The death evidence is Orca's log text, never a sentence for a person: the row says only + // that the provider stopped. body: { kind: 'status', - text: boundJournalStatusText(unexpectedProviderExitOutcome(input.deathEvidence?.detail)) + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...input.failureTextContext, + surface: 'row' + }) } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts new file mode 100644 index 00000000000..242daf74faf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-delivery-loop' +import type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionEndedChild +} from './structured-agent-session-host-types' + +const EXIT_FAILURE = agentSessionFailureFact('providerExited', { + detail: { text: 'crashed', audience: 'log' } +}) + +function ended( + cause: StructuredAgentSessionChildEndCause, + duringStartup: boolean +): StructuredAgentSessionEndedChild { + return { + generation: 'generation-1', + fence: 2, + rootGone: true, + cause, + reason: null, + failure: EXIT_FAILURE, + duringStartup, + endedAt: { epoch: 'epoch-1', sequence: 3 } + } +} + +describe('what a child end means for the messages queued behind it', () => { + it.each([false, true])("fails nothing after a user's Stop (during startup: %s)", (starting) => { + expect(structuredAgentSessionEndedChildFailure(ended('user-stop', starting))).toBeNull() + }) + + it.each([false, true])( + 'is a start Orca stopped after a host stop (during startup: %s)', + (starting) => { + expect(structuredAgentSessionEndedChildFailure(ended('host-stop', starting))).toEqual({ + failure: { kind: 'hostStopped' } + }) + } + ) + + it.each(['exit', 'attach-failed', 'evict'] as const)( + 'carries the recorded failure of a %s, as a failed start while starting', + (cause) => { + expect(structuredAgentSessionEndedChildFailure(ended(cause, true))).toEqual({ + exit: EXIT_FAILURE + }) + expect(structuredAgentSessionEndedChildFailure(ended(cause, false))).toEqual({ + failure: EXIT_FAILURE + }) + expect( + structuredAgentSessionEndedChildFailure({ ...ended(cause, false), failure: undefined }) + ).toEqual({ failure: agentSessionFailureFact('providerExited') }) + } + ) +}) 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 e12acb3a12e..7dd17306105 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 @@ -9,15 +9,22 @@ // 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 { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { - providerExitBeforeDeliveryRejection, - providerStartupFailureOutcome -} from './structured-agent-session-dead-generation-settlement' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' + structuredAgentSessionStartFailure, + type StructuredAgentSessionStartFailureCause +} from './structured-agent-session-failure-text' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { + StructuredAgentSessionChildEndCause, StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity @@ -36,11 +43,15 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** A start step, tracked from enqueue so quit waits for the child it may produce. */ trackStart: (start: Promise) => Promise /** Gives the session a provider child if it has none; for a caller inside `serialize`. */ - ensureProviderChild: (sessionId: string) => Promise + /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ + ensureProviderChild: ( + sessionId: string, + startedFor: string + ) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number - /** What the chat says when the session could not be made ready. */ - startFailureText: (sessionId: string, cause: AgentSessionWireRefusal) => string + /** Who the chat's failure sentences name. */ + failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void } @@ -48,10 +59,11 @@ type Step = 'continue' | 'stop' type Prepared = | 'stop' - | { ok: false; refusal: AgentSessionWireRefusal } + | Extract | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } -type StartFailure = { startKey: string | null; text: string } +/** A failed start before it is worded; `fail` words it once, through the one wording point. */ +type StartFailure = { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } export class StructuredAgentSessionDeliveryLoop { private readonly running = new Set() @@ -88,8 +100,11 @@ export class StructuredAgentSessionDeliveryLoop { return } if (!prepared.ok) { - const text = this.deps.startFailureText(sessionId, prepared.refusal) - await this.deps.serialize(sessionId, () => this.fail(sessionId, { startKey: null, text })) + const { refusal, diagnostic } = prepared + const cause = { refusal, ...(diagnostic ? { diagnostic } : {}) } + await this.deps.serialize(sessionId, () => + this.fail(sessionId, { startKey: null, cause }) + ) return } // A child published before it proved its start takes no input yet; waited for outside @@ -103,13 +118,11 @@ export class StructuredAgentSessionDeliveryLoop { } } } catch (error) { + // The error is Orca's own and goes to the log; the chat says only that Orca failed. this.deps.onError(sessionId, error) - const text = this.deps.startFailureText(sessionId, { - code: 'agent_session_owner_restart_failed', - message: error instanceof Error ? error.message : String(error) - }) + const cause = { hostFault: true } as const await this.deps - .serialize(sessionId, () => this.fail(sessionId, { startKey: null, text })) + .serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause })) .catch((failure: unknown) => { // Rows left queued are rejected by the next open, or by the next loop an accept wakes. this.running.delete(sessionId) @@ -126,7 +139,7 @@ export class StructuredAgentSessionDeliveryLoop { } await session.journal.rejectQueuedSubmissions( this.deps.conversationFence(sessionId), - DISPATCH_REJECTED_HOST_RESTARTED, + agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { surface: 'rejection' }), // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) @@ -138,7 +151,7 @@ export class StructuredAgentSessionDeliveryLoop { if (failedStart) { return this.fail(sessionId, failedStart) } - const ready = await this.deps.ensureProviderChild(sessionId) + const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) if (!ready.ok) { return ready } @@ -153,7 +166,7 @@ export class StructuredAgentSessionDeliveryLoop { private async handOver( sessionId: string, awaited: StructuredAgentSessionProviderChildIdentity | null, - startFailure: string | null + startFailure: SubmissionRejectionFact | null ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -171,14 +184,17 @@ export class StructuredAgentSessionDeliveryLoop { if (!awaitedChild || (awaitedChild.phase === 'starting' && startFailure !== null)) { // The child waited on is gone, replaced by another, or settled its start without proving it. const ended = awaitedChild ? undefined : session.lastEndedChild + const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined // A user's Stop is not a failure: the next step starts, or waits on, a child for what is - // queued. A host stop is: its cause is why the start did not land. - if (ended?.cause === 'user-stop') { + // queued. + if (endedFailure === null) { return 'continue' } return this.fail(sessionId, { startKey: awaited?.generation ?? null, - text: ended ? endedChildRejection(ended) : (startFailure ?? providerStartupFailureOutcome()) + cause: endedFailure ?? + // Gone with no end observed: nothing says the provider stopped. + { failure: startFailure ?? agentSessionFailureFact('startFailed') } }) } const next = oldestQueuedSubmission(session) @@ -191,7 +207,8 @@ export class StructuredAgentSessionDeliveryLoop { journal: session.journal, fence: awaitedChild.fence, adapter: this.deps.adapter, - providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase + providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, + failureTextContext: this.deps.failureTextContext(sessionId) }, next ) @@ -203,7 +220,13 @@ export class StructuredAgentSessionDeliveryLoop { if (session) { await recordStructuredAgentSessionStartFailure( { journal: session.journal, fence: this.deps.conversationFence(sessionId) }, - failure + { + startKey: failure.startKey, + ...structuredAgentSessionStartFailure( + failure.cause, + this.deps.failureTextContext(sessionId) + ) + } ) } return this.stop(sessionId) @@ -231,18 +254,37 @@ function startThatFailedWhileQueued( ) { return null } - return { startKey: ended.generation, text: endedChildRejection(ended) } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { startKey: ended.generation, cause } : null } -const HOST_STOPPED_BEFORE_DELIVERY = 'Orca stopped the agent before this message was sent.' - -/** Why a queued message the child never took is rejected, in the words the chat row uses. */ -function endedChildRejection(ended: StructuredAgentSessionEndedChild): string { - if (ended.cause === 'host-stop') { - return ended.reason ?? HOST_STOPPED_BEFORE_DELIVERY +function providerEndFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause { + if (ended.duringStartup) { + return { exit: ended.failure } } - const reason = ended.reason ?? undefined - return ended.duringStartup - ? providerStartupFailureOutcome(reason) - : providerExitBeforeDeliveryRejection(reason) + return { failure: ended.failure ?? agentSessionFailureFact('providerExited') } +} + +// Every end cause, so a new one does not compile until it says whether it fails what is queued. +const ENDED_CHILD_FAILURE = { + 'user-stop': () => null, + // The host stopping the child is Orca's cause, never the provider's: a start that never finished. + 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), + exit: providerEndFailure, + // The attach records its own fault as the end's failure. + 'attach-failed': providerEndFailure, + // Reached only when an eviction's stop landed and a later step failed, leaving the conversation. + evict: providerEndFailure +} satisfies Record< + StructuredAgentSessionChildEndCause, + (ended: StructuredAgentSessionEndedChild) => StructuredAgentSessionStartFailureCause | null +> + +/** Why a queued message the child never took is rejected; null when its end fails nothing. */ +export function structuredAgentSessionEndedChildFailure( + ended: StructuredAgentSessionEndedChild +): StructuredAgentSessionStartFailureCause | null { + return ENDED_CHILD_FAILURE[ended.cause](ended) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 4905cc19257..f7a50627370 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -1,3 +1,4 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { stopAgentSessionProviderRoot, type StructuredAgentSessionLifecycleEvent @@ -7,12 +8,8 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import type { StructuredAgentSessionHolds } from './structured-agent-session-holds' import { settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' -import { - isStructuredAgentSessionRecoveryTicketCurrent, - settleUnexpectedStructuredAgentSessionExit -} from './structured-agent-session-unexpected-exit' +import { settleUnexpectedStructuredAgentSessionExit } from './structured-agent-session-unexpected-exit' export class StructuredAgentSessionEventRecovery { private readonly sinkFailures = new Set() @@ -25,12 +22,8 @@ export class StructuredAgentSessionEventRecovery { flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void publishStatus?: (sessionId: string) => void - hasResumeCapableHolder: (sessionId: string) => boolean - restartReleaseGrace: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number - /** The one restart every asker shares; the holds put an unheld child on the idle clock. */ - ensureProviderChild: StructuredAgentSessionHolds['ensureProviderChild'] onBarrierError: (sessionId: string, error: unknown) => void } ) {} @@ -57,6 +50,8 @@ export class StructuredAgentSessionEventRecovery { type: 'ended', sessionId, reason: `journal sink failure: ${error instanceof Error ? error.message : String(error)}`, + // Orca stopped the provider because its own journal failed. + failure: agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence, acquisitionGeneration @@ -67,34 +62,12 @@ export class StructuredAgentSessionEventRecovery { .finally(() => this.sinkFailures.delete(sessionId)) } + /** An exit is settled and shown; nothing restarts the child. The next send does, through the + * delivery loop, which also owns any message still queued. */ async handle(event: StructuredAgentSessionLifecycleEvent): Promise { if (event.type === 'started') { return settleStructuredAgentSessionProviderStarted(this.context, event) } - const ticket = await settleUnexpectedStructuredAgentSessionExit(this.context, event) - if (!ticket) { - return - } - // One serialized step with the ticket check inside it: a hold or a send that got there first - // has already replaced the owner, and this step finds that child and attaches nothing — or, - // once the lease has moved on, refuses on the stale ticket rather than spawning a second child. - try { - const resumed = await this.context.serialize(ticket.sessionId, () => - this.context.ensureProviderChild(ticket.sessionId, { - admitRecoveryTicket: () => - isStructuredAgentSessionRecoveryTicketCurrent(this.context, ticket) - }) - ) - if (!resumed.ok && isStructuredAgentSessionRecoveryTicketCurrent(this.context, ticket)) { - this.context.onBarrierError( - ticket.sessionId, - new Error(`${resumed.refusal.code}: ${resumed.refusal.message}`) - ) - } - } catch (error) { - if (isStructuredAgentSessionRecoveryTicketCurrent(this.context, ticket)) { - this.context.onBarrierError(ticket.sessionId, error) - } - } + await settleUnexpectedStructuredAgentSessionExit(this.context, event) } } 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 ee664ebc0d4..317e7ca81a0 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 @@ -13,6 +13,7 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { withObservedProviderExit } from './structured-agent-session-failure-text' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -23,6 +24,10 @@ import { const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' +// The refusal says what the chat's start failure says; the error text stays in the log. +const COULD_NOT_RESTART = "Codex couldn't restart. Send your message to try again." +const PROVIDER_STOPPED = + 'Codex stopped before it finished starting. Send your message to try again.' let root: string let store: AgentSessionRecordStore @@ -66,47 +71,75 @@ afterEach(async () => { }) describe('failed create owner verdict', () => { - it('answers an exit-proven failure as exited on the first call and its replay, and a new operation starts fresh', async () => { - // The cleanup's release proves the whole tree gone: the common failed start. - acquire.mockRejectedValueOnce(new Error(EXIT_REASON)) - const first = hostTestAttachParams(null) - const refusal = { - code: 'agent_session_operation_invalid', - message: EXIT_REASON, - ownerVerdict: 'exited' + it.each([ + // The cleanup's release proves the whole tree gone, which says nothing about why it failed. + ['a failure the cleanup proved gone', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], + // Only an exit the adapter saw says the provider stopped. + [ + 'an exit the adapter observed', + () => withObservedProviderExit(new Error(EXIT_REASON)), + { reason: 'providerStartFailed' }, + PROVIDER_STOPPED + ] + ])( + 'answers %s as exited on the first call and its replay, and a new operation starts fresh', + async (_case, failure, situation, message) => { + acquire.mockRejectedValueOnce(failure()) + const first = hostTestAttachParams(null) + // The replay names the same details as the first answer: the ledger kept them beside the code, + // and the verdict reaches released clients at the top level exactly as before. + const refusal = { + code: 'agent_session_operation_invalid', + details: { ...situation, ownerVerdict: 'exited' }, + message, + ownerVerdict: 'exited' + } + + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + expect(acquire).toHaveBeenCalledOnce() + + const retry = hostTestAttachParams(null) + expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) + await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') } + ) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - expect(acquire).toHaveBeenCalledOnce() + it.each([ + // A cleanup that saw the root go may have stopped it itself. + ['a root exit the cleanup saw', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], + [ + 'a root exit the adapter observed', + () => withObservedProviderExit(new Error(EXIT_REASON)), + { reason: 'providerStartFailed' }, + PROVIDER_STOPPED + ] + ])( + 'answers %s as exited on the first call, in the shape its replay takes', + async (_case, cause, situation, message) => { + acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRootExitObservedError(cause())) + const first = hostTestAttachParams(null) + // The replay names the same details as the first answer: the ledger kept them beside the code, + // and the verdict reaches released clients at the top level exactly as before. + const refusal = { + code: 'agent_session_operation_invalid', + details: { ...situation, ownerVerdict: 'exited' }, + message, + ownerVerdict: 'exited' + } - const retry = hostTestAttachParams(null) - expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) - await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + expect(acquire).toHaveBeenCalledOnce() - it('answers a first-hand root exit as exited on the first call, in the shape its replay takes', async () => { - acquire.mockRejectedValueOnce( - new AgentSessionAcquisitionRootExitObservedError(new Error(EXIT_REASON)) - ) - const first = hostTestAttachParams(null) - const refusal = { - code: 'agent_session_operation_invalid', - message: EXIT_REASON, - ownerVerdict: 'exited' + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) + expect(acquire).toHaveBeenCalledTimes(2) } - - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - expect(acquire).toHaveBeenCalledOnce() - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - expect(acquire).toHaveBeenCalledTimes(2) - }) + ) it('answers an acquisition refusal with its verdict directly', async () => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRefusal('not signed in')) @@ -115,7 +148,8 @@ describe('failed create owner verdict', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: 'not signed in', + details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + message: PROVIDER_STOPPED, ownerVerdict: 'exited' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index a5e00fde53c..90f52db3d09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -1,7 +1,12 @@ -import type { - AgentSessionAttachResult, - AgentSessionMutationResult, - AgentSessionWireRefusal +import { + isAgentSessionWireRefusalCode, + refuse, + refuseUnclassified, + withAgentSessionRefusalFacts, + type AgentSessionAttachResult, + type AgentSessionMutationResult, + type AgentSessionRefusalDetailsByCode, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionOperationOutcome, @@ -15,16 +20,50 @@ import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRefusal, AgentSessionAcquisitionRootExitObservedError, + AgentSessionPreSpawnError, isAgentSessionPreSpawnError } from './structured-agent-session-adapter' +import { + providerExitObserved, + structuredAgentSessionStartFailure +} from './structured-agent-session-failure-text' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' + +/** Who a failed acquisition's sentence names, and whether it was the session's first start. */ +export type FailedAcquisitionWording = { + record: AgentSessionRecord | null + newSession: boolean +} + +/** The refusal a failed acquisition answers with, worded as the start failure it records: its + * error text is Orca's or the provider's log wording, so it goes to the log, never the wire. */ +function failedAcquisitionWireRefusal( + details: AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined, + wording: FailedAcquisitionWording +): AgentSessionWireRefusal { + const code = 'agent_session_operation_invalid' + const refusal = details ? refuse(code, details, code) : refuseUnclassified(code, code) + const { reason } = structuredAgentSessionStartFailure( + { refusal, ...(wording.newSession ? { newSession: true as const } : {}) }, + structuredAgentSessionFailureWordsContext(wording.record) + ) + return { ...refusal, message: reason } +} /** What a failed acquisition proved about its process, and the outcome its operation settles to. */ -export function failedAcquisitionSettlement(error: unknown): { +export function failedAcquisitionSettlement( + error: unknown, + wording: FailedAcquisitionWording +): { exitProof: AgentSessionAcquisitionExitProof outcome: Extract } { if (error instanceof AgentSessionAcquisitionExitUnprovenError) { - const outcome = { code: 'agent_session_ownership_unknown', message: error.message } + const outcome = { + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' as const }, + message: error.message + } return { exitProof: 'unproven', outcome: { status: 'failed', ...outcome } } } const exitProof = isAgentSessionPreSpawnError(error) @@ -32,33 +71,79 @@ export function failedAcquisitionSettlement(error: unknown): { : error instanceof AgentSessionAcquisitionRootExitObservedError ? 'root-exit-observed' : 'exit-proven' - const message = error instanceof Error ? error.message : String(error) - const code = - error instanceof AgentSessionAcquisitionRefusal ? error.code : 'agent_session_operation_invalid' - return { exitProof, outcome: { status: 'failed', code, message } } + const raw = error instanceof Error ? error.message : String(error) + const details = failedAcquisitionDetails(error) + const message = + failedAcquisitionRefusal(error, wording)?.refusal.message ?? + // A store refusal's message is its code, which its replay has always carried. + (isAgentSessionWireRefusalCode(raw) + ? raw + : failedAcquisitionWireRefusal(details, wording).message) + return { + exitProof, + outcome: { + status: 'failed', + code: 'agent_session_operation_invalid', + ...(details ? { details } : {}), + message + } + } +} + +/** Cleanup proved the child gone after the start failed, whatever failed it. */ +function isExitProvenAcquisitionFailure(error: unknown): error is Error { + return ( + error instanceof AgentSessionAcquisitionRootExitObservedError || + error instanceof AgentSessionAcquisitionExitProvenError + ) +} + +/** The situation a failed acquisition stands for: what the adapter typed, or a provider the + * adapter saw exit while starting. A provider refusing a request, a timeout, a store refusal or + * Orca's own fault names none: the child being gone now says nothing about why. */ +function failedAcquisitionDetails( + error: unknown +): AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined { + if (error instanceof AgentSessionAcquisitionRefusal) { + return { reason: error.reason } + } + if (isAgentSessionPreSpawnError(error) && error.reason) { + return { reason: error.reason } + } + if (isExitProvenAcquisitionFailure(error) && providerExitObserved(error)) { + return { reason: 'providerStartFailed' } + } + return undefined } /** A failed acquisition answered as a refusal on the first call, in the shape its replay takes; * null leaves the error to the store-failure classification. */ export function failedAcquisitionRefusal( - error: unknown + error: unknown, + wording: FailedAcquisitionWording ): { ok: false; refusal: AgentSessionWireRefusal } | null { - if (error instanceof AgentSessionAcquisitionRefusal) { - return { ok: false, refusal: { code: error.code, message: error.message } } - } - // A proven exit is a settled fact; its message is the provider's own diagnostic. - if ( - error instanceof AgentSessionAcquisitionRootExitObservedError || - error instanceof AgentSessionAcquisitionExitProvenError - ) { + // A proven exit is a settled failure, answered in the shape its ledger row replays. + if (error instanceof AgentSessionAcquisitionRefusal || isExitProvenAcquisitionFailure(error)) { return { ok: false, - refusal: { code: 'agent_session_operation_invalid', message: error.message } + refusal: failedAcquisitionWireRefusal(failedAcquisitionDetails(error), wording) } } return null } +/** A start that failed before any process still throws, in the sentence its replay reads: the + * error's own text is Orca's or the refusing site's, so it goes to the log. A message that is + * itself a code keeps it, since the wire routes on it. */ +export function preSpawnFailureInWords(error: unknown, wording: FailedAcquisitionWording): unknown { + if (!isAgentSessionPreSpawnError(error) || /^[a-z0-9_]+$/.test(error.message)) { + return error + } + return new AgentSessionPreSpawnError(error, { + message: failedAcquisitionSettlement(error, wording).outcome.message + }) +} + /** Only a durably failed operation says anything about retrying under a new one. */ export function failedCreateRefusal( refusal: AgentSessionWireRefusal, @@ -68,7 +153,9 @@ export function failedCreateRefusal( return status === 'failed' && record ? { ok: false, - refusal: { ...refusal, ownerVerdict: agentSessionLeaseOwnerVerdict(record.lease) } + refusal: withAgentSessionRefusalFacts(refusal, { + ownerVerdict: agentSessionLeaseOwnerVerdict(record.lease) + }) } : { ok: false, refusal } } 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 c61d93cbb8f..69f9b95c03a 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 @@ -82,8 +82,11 @@ describe('a create that fails after its child wrote through the unbound sink', ( const failed = host.attach(CALLER, hostTestAttachParams(null)) await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow(EXIT_REASON) - : expect(failed).resolves.toMatchObject({ ok: false, refusal: { message: EXIT_REASON } })) + ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") + : expect(failed).resolves.toMatchObject({ + ok: false, + refusal: { message: "Codex couldn't restart. Send your message to try again." } + })) await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true @@ -118,7 +121,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( // The session stays indexed across this failure: it is a resume, not a create. const failed = host.attach(CALLER, hostTestAttachParams(releasedFence)) await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow(EXIT_REASON) + ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") : expect(failed).resolves.toMatchObject({ ok: false })) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 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 new file mode 100644 index 00000000000..fcebdda7726 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic, + withProviderDiagnostic +} from '../../../shared/agent-session-failure' +import { + AgentSessionRefusalError, + refuse, + refuseUnclassified +} from '../../../shared/agent-session-wire-refusals' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' +import { MAX_UNEXPECTED_EXIT_REASON_CHARS } from './structured-agent-session-dead-generation-settlement' +import { + structuredAgentSessionStartFailure, + withObservedProviderExit +} from './structured-agent-session-failure-text' + +/** What Orca's own error text looks like: a code, a marker, a uuid, a path, an exception. */ +const ORCA_INTERNAL = + /agent_session_|execution_owner|provider_[a-z_]+|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ + +describe('structuredAgentSessionStartFailure', () => { + it('keeps a provider diagnostic only when the error carried one', () => { + const carried = withProviderDiagnostic( + new Error('claude stream-json exited (code 1): boom'), + providerDiagnostic('code 1\nboom', 'log') + ) + expect(structuredAgentSessionStartFailure({ error: carried }, { agentName: 'Claude' })).toEqual( + { + reason: "Claude couldn't start. Send your message to try again.", + rejection: { kind: 'startFailed', detail: { text: 'code 1\nboom', audience: 'log' } } + } + ) + // Orca's own words, however provider-like, are never promoted to a detail. + expect( + structuredAgentSessionStartFailure({ error: new Error('Not logged in. Run /login.') }) + .rejection + ).toEqual({ kind: 'startFailed' }) + }) + + it('keeps a start refusal the adapter typed', () => { + const refusal = new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account.', + 'notSignedIn' + ) + expect(structuredAgentSessionStartFailure({ error: refusal }, { agentName: 'Claude' })).toEqual( + { + reason: + 'Claude is not signed in for the selected account. Sign in, then send your message again.', + rejection: { kind: 'notSignedIn' } + } + ) + }) + + it.each([ + [ + 'managedAccountEnvOverride', + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + ], + [ + 'accountSwitchInProgress', + 'A Claude account switch is in progress. Try again after it finishes.' + ], + [ + 'managedAccountUnsupported', + 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ] + ] as const)('words a start refused for %s by that situation', (reason, sentence) => { + const code = 'agent_session_operation_invalid' + expect( + structuredAgentSessionStartFailure( + { refusal: refuse(code, { reason }, code) }, + { agentName: 'Claude' } + ) + ).toEqual({ reason: sentence, rejection: { kind: reason } }) + }) + + it('words a refused restart by its situation, never its message', () => { + const words = structuredAgentSessionStartFailure( + { + refusal: refuse( + 'agent_session_ownership_unknown', + { reason: 'ownerUnproven' }, + 'Orca cannot prove that process 4242 on host-1 has exited.' + ) + }, + { agentName: 'Claude' } + ) + expect(words).toEqual({ + reason: "Claude couldn't restart. Send your message to try again.", + rejection: { + kind: 'restartFailed', + refusal: { + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } + } + }) + }) + + it("reads an exit before the start as a failed start, and an Orca fault as Orca's", () => { + expect( + structuredAgentSessionStartFailure({ + exit: { kind: 'providerExited', detail: { text: 'stderr', audience: 'log' } } + }).rejection + ).toEqual({ kind: 'providerStartFailed', detail: { text: 'stderr', audience: 'log' } }) + expect(structuredAgentSessionStartFailure({ exit: { kind: 'hostFault' } }).rejection).toEqual({ + kind: 'hostFault' + }) + expect(structuredAgentSessionStartFailure({ hostFault: true }).reason).not.toMatch( + ORCA_INTERNAL + ) + }) + + 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. + const words = structuredAgentSessionStartFailure({ + diagnostic: { text: long, audience: 'log' } + }) + expect(words.rejection.detail?.text).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) + expect( + structuredAgentSessionStartFailure({ + exit: { kind: 'providerExited', detail: { text: long, audience: 'log' } } + }).rejection.detail?.text + ).toHaveLength(MAX_UNEXPECTED_EXIT_REASON_CHARS) + }) + + it('says the provider stopped only when an exit was observed', () => { + // An `exited` verdict says only that nothing runs now, not that the provider stopped. + const gone = refuseUnclassified('agent_session_operation_invalid', 'thread gone', { + ownerVerdict: 'exited' + }) + const person = { text: 'no rollout found for thread id T', audience: 'person' } as const + expect( + structuredAgentSessionStartFailure( + { refusal: gone, diagnostic: person }, + { agentName: 'Codex' } + ) + ).toEqual({ + reason: "Codex couldn't restart. Send your message to try again.", + rejection: { + kind: 'restartFailed', + detail: person, + refusal: { + code: 'agent_session_operation_invalid', + details: { ownerVerdict: 'exited' } + } + } + }) + const observed = refuse( + 'agent_session_operation_invalid', + { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + 'exited (code 1)' + ) + expect( + structuredAgentSessionStartFailure({ + refusal: observed, + diagnostic: { text: 'code 1', audience: 'log' } + }) + ).toEqual({ + reason: 'The agent stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } + }) + // A start that threw proves nothing about the provider: it may be Orca's, or a failed spawn. + expect( + structuredAgentSessionStartFailure({ error: new Error('spawn claude ENOENT') }, {}) + ).toEqual({ + reason: "The agent couldn't start. Send your message to try again.", + rejection: { kind: 'startFailed' } + }) + // The same error, once the adapter that observed the child's exit marked it, blames the provider. + const exit = withObservedProviderExit( + withProviderDiagnostic(new Error('exited (code 1)'), providerDiagnostic('code 1', 'log')) + ) + expect( + structuredAgentSessionStartFailure({ error: new Error('wrapped', { cause: exit }) }) + ).toEqual({ + reason: 'The agent stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed', detail: { text: 'code 1', audience: 'log' } } + }) + }) + + it('reads a thrown refusal the same as a returned one', () => { + const thrown = new AgentSessionRefusalError( + refuse( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + 'Another process claims this session.' + ) + ) + expect(thrown.message).toBe('agent_session_conflict') + expect(structuredAgentSessionStartFailure({ refusal: thrown.refusal }).rejection).toEqual({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts new file mode 100644 index 00000000000..c0c2172230d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -0,0 +1,163 @@ +// The facts a start or an exit reduces to, and the one place a failed start is worded. The +// sentence itself comes from `agentSessionFailureWords`, beside the fact it states. + +import { + agentSessionFailureFact, + providerDiagnosticOf, + type SubmissionRejectionFact, + type AgentSessionFailureKind, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' +import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { + agentSessionRefusalReference, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire-refusals' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' + +/** Start refusals whose situation is itself what the person reads, with its own next step. */ +const TYPED_START_REFUSALS = [ + 'notSignedIn', + 'historyTooLarge', + 'managedAccountEnvOverride', + 'accountSwitchInProgress', + 'managedAccountUnsupported' +] as const satisfies readonly (AgentSessionFailureKind & + AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] + +function typedStartRefusal( + reason: string | undefined +): (typeof TYPED_START_REFUSALS)[number] | undefined { + return TYPED_START_REFUSALS.find((typed) => typed === reason) +} + +/** Marks the error an adapter observed its child's exit with, where it observed it. */ +export function withObservedProviderExit(error: TError): TError { + return Object.assign(error, { providerExitObserved: true }) +} + +/** Whether the adapter saw the child exit on its own. Follows `cause` and the errors a cleanup + * aggregated, since the acquisition errors wrap what the adapter threw. */ +export function providerExitObserved(error: unknown, depth = 0): boolean { + if (depth >= 6 || !(error instanceof Error)) { + return false + } + if ('providerExitObserved' in error && error.providerExitObserved === true) { + return true + } + if ( + error instanceof AggregateError && + error.errors.some((inner) => providerExitObserved(inner, depth + 1)) + ) { + return true + } + return providerExitObserved(error.cause, depth + 1) +} + +/** A start that did not land. A refusal the adapter typed keeps its situation, and an exit the + * adapter observed says the provider stopped; anything else blames no one — it may be Orca's, or + * a spawn that failed. Either keeps the provider's diagnostic when the error carried one. */ +export function providerStartupFailureFact(cause?: unknown): SubmissionRejectionFact { + const typed = typedStartRefusal( + cause instanceof AgentSessionAcquisitionRefusal ? cause.reason : undefined + ) + if (typed) { + return agentSessionFailureFact(typed) + } + return agentSessionFailureFact( + providerExitObserved(cause) ? 'providerStartFailed' : 'startFailed', + { detail: providerDiagnosticOf(cause) } + ) +} + +/** A child that ended before it proved its start: an exit is a start that failed, keeping the + * provider's diagnostic; an Orca fault or a typed start refusal stays what it was. */ +function startupFailureFromExit( + failure: SubmissionRejectionFact | undefined +): SubmissionRejectionFact { + if (!failure || failure.kind === 'providerExited') { + return agentSessionFailureFact('providerStartFailed', { detail: failure?.detail }) + } + return failure +} + +/** What the chat records when a session could not be made ready. Only a refusal the host typed as + * an observed exit says the provider stopped; a verdict of `exited` means only that nothing runs + * now. */ +function refusedStartFailureFact( + cause: Extract +): SubmissionRejectionFact { + const { refusal, diagnostic } = cause + const reason = refusal.details?.reason + const typed = typedStartRefusal(reason) + if (typed) { + return agentSessionFailureFact(typed) + } + if (reason === 'providerStartFailed') { + return agentSessionFailureFact('providerStartFailed', { detail: diagnostic }) + } + return agentSessionFailureFact(cause.newSession ? 'startFailed' : 'restartFailed', { + detail: diagnostic, + refusal: agentSessionRefusalReference(refusal) + }) +} + +/** Why a start the chat needed did not land, as the place that saw it knows it. */ +export type StructuredAgentSessionStartFailureCause = + /** The session could not be made ready; the provider's words, if any, are kept host-side, off + * the refusal. `newSession`: one that never ran, so it failed to start rather than restart. */ + | { refusal: AgentSessionWireRefusal; diagnostic?: ProviderDiagnostic; newSession?: true } + /** A start that threw, or an adapter's own startup failure; any diagnostic it carries. */ + | { error: unknown } + /** The child ended before it proved its start, as its ended event told it. */ + | { exit: SubmissionRejectionFact | undefined } + /** The provider exited while starting; only its words are known, and the caller names their + * audience. */ + | { diagnostic: ProviderDiagnostic | undefined } + /** Orca's own fault; its error belongs in the log. */ + | { hostFault: true } + /** Already typed where it was observed. */ + | { failure: SubmissionRejectionFact } + +/** A start failure's row repeats the sentence its rejected messages carry: both are about the + * messages the start was for. */ +export type StructuredAgentSessionStartFailureWords = AgentJournalDispatchRejection + +/** The fact a failed start records, for a writer that words it on its own surface. */ +export function structuredAgentSessionStartFailureFact( + cause: StructuredAgentSessionStartFailureCause +): SubmissionRejectionFact { + if ('refusal' in cause) { + return refusedStartFailureFact(cause) + } + if ('error' in cause) { + return providerStartupFailureFact(cause.error) + } + if ('exit' in cause) { + return startupFailureFromExit(cause.exit) + } + if ('diagnostic' in cause) { + return agentSessionFailureFact('providerStartFailed', { detail: cause.diagnostic }) + } + if ('hostFault' in cause) { + return agentSessionFailureFact('hostFault') + } + return cause.failure +} + +/** The one place a failed start is worded: the error row and every message it rejects carry this + * sentence and this fact, whichever writer saw the start fail. */ +export function structuredAgentSessionStartFailure( + cause: StructuredAgentSessionStartFailureCause, + context: AgentSessionFailureWordsContext = {} +): StructuredAgentSessionStartFailureWords { + return agentSessionFailureWords(structuredAgentSessionStartFailureFact(cause), { + ...context, + surface: 'rejection' + }) +} 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 1ffc08a0249..90e07ddf88c 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 @@ -186,7 +186,7 @@ function attachContext( reconcileLeases: async () => null, serialize: (_sessionId: string, task: () => Promise) => task(), now: () => 1, - forgetStatus: (sessionId: string) => feed.forget(sessionId) + publishStatus: (sessionId: string) => feed.publish(sessionId) } as unknown as StructuredAgentSessionAttachContext } 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 013f4191c30..8181110a5a6 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 @@ -96,7 +96,7 @@ async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null if (!appended) { throw new Error('provider question was not written to the journal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts deleted file mode 100644 index b1004f7e396..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume-race.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' -import { StructuredAgentSessionHolds } from './structured-agent-session-holds' - -const GRACE_MS = 15_000 -const pendingHolds: StructuredAgentSessionHolds[] = [] - -/** The host's per-session queue: a second hold waits for the attach the first one is running. */ -function keyedSerialize() { - const chains = new Map>() - return (sessionId: string, task: () => Promise) => - runKeyedSerializedOperation(chains, sessionId, task) -} - -function resumeHarness() { - const resumeGate = Promise.withResolvers() - let child = false - let turnActive = false - const evict = vi.fn(async () => { - child = false - }) - const resume = vi.fn(async () => { - await resumeGate.promise - child = true - return { ok: true as const } - }) - const holds = new StructuredAgentSessionHolds({ - resume, - serialize: keyedSerialize(), - hasProviderChild: () => child, - lastStartFailed: () => false, - hasOwedWork: () => turnActive, - evict, - graceMs: GRACE_MS - }) - pendingHolds.push(holds) - return { - holds, - resume, - resumeGate, - evict, - hasChild: () => child, - setTurnActive: (value: boolean) => { - turnActive = value - } - } -} - -beforeEach(() => vi.useFakeTimers()) -afterEach(() => { - for (const holds of pendingHolds.splice(0)) { - holds.dispose() - } - vi.useRealTimers() -}) - -describe('a surface leaving while its structured session resumes', () => { - it('releases the acquired child after the last surface disconnects during resume', async () => { - const { holds, resumeGate, evict, hasChild } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - - holds.release('session-1', 'connection-1:chat') - expect(holds.isReleasePending('session-1')).toBe(false) - resumeGate.resolve() - await hold - - expect(hasChild()).toBe(true) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(true) - await vi.advanceTimersByTimeAsync(GRACE_MS - 1) - expect(evict).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - expect(hasChild()).toBe(false) - }) - - it('waits for an active turn before releasing the late child', async () => { - const { holds, resumeGate, evict, setTurnActive } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - setTurnActive(true) - resumeGate.resolve() - await hold - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(evict).not.toHaveBeenCalled() - expect(holds.isReleasePending('session-1')).toBe(true) - - setTurnActive(false) - await vi.advanceTimersByTimeAsync(GRACE_MS) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - }) - - it.each([false, true])('preserves an arriving holder with resume=%s', async (resume) => { - const { holds, resumeGate, evict } = resumeHarness() - const first = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - const replacement = holds.hold('session-1', 'connection-2:chat', { resume }) - resumeGate.resolve() - await Promise.all([first, replacement]) - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isHeld('session-1')).toBe(true) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - - holds.release('session-1', 'connection-2:chat') - await vi.advanceTimersByTimeAsync(GRACE_MS) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - }) - - it('cancels the late-child release when a surface reconnects during grace', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - resumeGate.resolve() - await hold - expect(holds.isReleasePending('session-1')).toBe(true) - - await holds.hold('session-1', 'connection-2:chat') - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('preserves a failed resume without scheduling eviction', async () => { - const { holds, resumeGate, evict, hasChild } = resumeHarness() - const failure = new Error('provider acquisition failed') - const hold = holds.hold('session-1', 'connection-1:chat') - const rejected = expect(hold).rejects.toBe(failure) - holds.release('session-1', 'connection-1:chat') - resumeGate.reject(failure) - await rejected - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(hasChild()).toBe(false) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('leaves late acquisition cleanup to host teardown after disposal', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.release('session-1', 'connection-1:chat') - holds.dispose() - resumeGate.resolve() - await hold - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('does not restart release timers when a surface leaves after disposal', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - resumeGate.resolve() - await hold - holds.dispose() - holds.release('session-1', 'connection-1:chat') - - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('releases a late child acquired after explicit close forgot its holders', async () => { - const { holds, resumeGate, evict } = resumeHarness() - const hold = holds.hold('session-1', 'connection-1:chat') - holds.forget('session-1') - resumeGate.resolve() - await hold - - await vi.advanceTimersByTimeAsync(GRACE_MS) - expect(holds.isHeld('session-1')).toBe(false) - expect(evict).toHaveBeenCalledExactlyOnceWith('session-1') - }) - - it('lets a holder that left and came back make its own attempt behind a failing one, and its failure releases it', async () => { - const { holds, resume, resumeGate, evict } = resumeHarness() - const first = holds.hold('session-1', 'same-holder') - const firstRejected = expect(first).rejects.toThrow('acquisition failed') - holds.release('session-1', 'same-holder') - const replacement = holds.hold('session-1', 'same-holder') - const replacementRejected = expect(replacement).rejects.toThrow('acquisition failed') - await vi.advanceTimersByTimeAsync(0) - expect(holds.isHeld('session-1')).toBe(true) - expect(resume).toHaveBeenCalledOnce() - - // The gate stays rejected, so the replacement's own attempt fails the same way. - resumeGate.reject(new Error('acquisition failed')) - await Promise.all([firstRejected, replacementRejected]) - - expect(resume).toHaveBeenCalledTimes(2) - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(false) - await vi.advanceTimersByTimeAsync(GRACE_MS * 2) - expect(evict).not.toHaveBeenCalled() - }) - - it('keeps a re-hold that finds the child the failing attempt ahead of it left behind', async () => { - const gate = Promise.withResolvers() - let child = false - const holds = new StructuredAgentSessionHolds({ - // The child is up before the attempt settles, and then the attempt fails behind it. - resume: async () => { - child = true - await gate.promise - return { ok: true as const } - }, - serialize: keyedSerialize(), - hasProviderChild: () => child, - lastStartFailed: () => false, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: GRACE_MS - }) - pendingHolds.push(holds) - const first = holds.hold('session-1', 'same-holder') - const rejected = expect(first).rejects.toThrow('acquisition failed') - holds.release('session-1', 'same-holder') - const replacement = holds.hold('session-1', 'same-holder') - - gate.reject(new Error('acquisition failed')) - await rejected - // The first hold's failure released only the holder it added, at the incarnation it added; - // the replacement then ran, found the child, and kept the holder it re-took. - await replacement - - expect(holds.isHeld('session-1')).toBe(true) - expect(holds.isReleasePending('session-1')).toBe(false) - }) - - it('starts a fresh resume once the failed one has settled', async () => { - let child = false - const resume = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('first acquisition failed')) - .mockImplementationOnce(async () => { - child = true - return { ok: true } - }) - const holds = new StructuredAgentSessionHolds({ - resume, - serialize: keyedSerialize(), - hasProviderChild: () => child, - lastStartFailed: () => false, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: GRACE_MS - }) - pendingHolds.push(holds) - - await expect(holds.hold('session-1', 'chat-1')).rejects.toThrow('first acquisition failed') - await holds.hold('session-1', 'chat-1') - - expect(resume).toHaveBeenCalledTimes(2) - expect(holds.isHeld('session-1')).toBe(true) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts deleted file mode 100644 index b29af8c06bb..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Giving a session its provider child back. -// -// This is the replacement for the startup resume, and the difference is only in WHO asks: the same -// eligibility rule, run when a surface binds, when a send finds the owner gone, or when a child -// exits under an open surface — never when the app launches. It runs inside the session's -// serialize, with the attach it is given, so the eligibility it reads is the one the attach acts -// on. A write-capable hold must fail when acquisition is refused so the surface never mistakes a -// readable journal for a live provider child. - -import type { - AgentSessionAttachResult, - AgentSessionMutationResult, - AgentSessionWireRefusal -} from '../../../shared/agent-session-wire' -import { isAgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' -import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' -import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { - attachStructuredAgentSessionUnderSerialize, - type StructuredAgentSessionAttachOptions -} from './structured-agent-session-attach-orchestration' -import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' -import { - structuredAgentSessionResumeOperationId, - structuredAgentSessionResumeParams -} from './structured-agent-session-resume-eligibility' - -/** A resume answers with the attach's own refusal, verdict and all, so the asker can tell a lease - * someone else is settling from an owner that will not come back. */ -export type StructuredAgentSessionResumeOutcome = - | { ok: true } - | { ok: false; refusal: AgentSessionWireRefusal } - -export async function resumeHeldStructuredAgentSession(input: { - sessionId: string - context: StructuredAgentSessionAttachContext - /** Who is asking; the attach keys the ledger row it settles by it. */ - callerKey: string - attachOptions?: StructuredAgentSessionAttachOptions -}): Promise { - const { sessionId, context, callerKey } = input - // The record is read only once this host has adjudicated it and recovery resolution has - // concluded about any owner a failed attempt left in `recovering`, so the eligibility below sees - // the lease the resolver handed back. - const unreconciled = await context.reconcileLeases(sessionId) - if (unreconciled) { - return { ok: false, refusal: unreconciled } - } - await context.runtimeState.resolveRecovery(sessionId) - const record = context.deps.store.getRecord(sessionId) - if (!record) { - return refuse('agent_session_identity_required', 'No structured session exists by that id.') - } - if (!adapterSupportsRecord(context.deps.adapter, record)) { - return refuse( - 'structured_agent_session_unsupported', - 'This execution host cannot resume the requested structured agent session.' - ) - } - const params = structuredAgentSessionResumeParams( - record, - structuredAgentSessionResumeOperationId(context.now()) - ) - if (!params) { - return record.lease.unreconciled - ? refuse( - 'execution_owner_reconciling', - 'This host has not yet adjudicated the session lease.' - ) - : record.lease.claimStatus === 'conflicted' - ? refuse('agent_session_conflict', terminalOwnerRefusalMessage(record.lease)) - : refuse( - 'agent_session_ownership_unknown', - 'The session lease is not one this host may resume.' - ) - } - let attached: AgentSessionMutationResult - try { - attached = await attachStructuredAgentSessionUnderSerialize( - context, - callerKey, - params, - input.attachOptions - ) - } catch (error) { - // The attach settles an acquisition that failed — the ledger row, the released lease — before - // it rethrows the cause. That row is the answer: a failure it recorded is this resume's - // refusal, verdict and all. Only an error it did not record is a fault for the caller. - const settled = settledResumeRefusal( - context, - callerKey, - params.envelope.clientOperationId, - sessionId, - error - ) - if (settled) { - return settled - } - throw error - } - return attached.ok ? { ok: true } : { ok: false, refusal: attached.refusal } -} - -function settledResumeRefusal( - context: Pick, - callerKey: string, - operationId: string, - sessionId: string, - error: unknown -): StructuredAgentSessionResumeOutcome | null { - const outcome = context.deps.store.getOperationRow(callerKey, operationId)?.outcome - if (outcome?.status !== 'failed' || !isAgentSessionWireRefusalCode(outcome.code)) { - return null - } - return failedCreateRefusal( - { - code: outcome.code, - message: outcome.message ?? (error instanceof Error ? error.message : String(error)) - }, - outcome.status, - context.deps.store.getRecord(sessionId) - ) -} - -function refuse( - code: AgentSessionWireRefusal['code'], - message: string -): StructuredAgentSessionResumeOutcome { - return { ok: false, refusal: { code, message } } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts deleted file mode 100644 index 531717168b1..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Who WANTS this session alive, as a set of ids rather than a count. -// -// A refcount is the obvious shape and the wrong one. Every path that decrements it — a chat tab -// closing, a transport dying, a client retrying a release it already sent — can fire twice or not -// at all, and an integer cannot tell those apart: a duplicate release evicts a session somebody is -// still looking at, and a lost one leaks the child forever. A set answers both idempotently, -// because it records WHICH surface holds the session, not how many do. - -type Holder = { resumeCapable: boolean; incarnation: symbol } - -export class StructuredAgentSessionHolders { - private readonly bySession = new Map>() - - /** True when the session gained its FIRST holder — the edge that ends a pending release. */ - add(sessionId: string, holderId: string, resumeCapable = true): boolean { - const holders = this.bySession.get(sessionId) - if (!holders) { - this.bySession.set(sessionId, new Map([[holderId, { resumeCapable, incarnation: Symbol() }]])) - return true - } - const previous = holders.get(holderId) - holders.set(holderId, { - resumeCapable: (previous?.resumeCapable ?? false) || resumeCapable, - incarnation: previous?.incarnation ?? Symbol() - }) - return false - } - - /** True when the session lost its LAST holder — the edge that starts one. */ - remove(sessionId: string, holderId: string, expectedIncarnation?: symbol): boolean { - const holders = this.bySession.get(sessionId) - if ( - expectedIncarnation !== undefined && - holders?.get(holderId)?.incarnation !== expectedIncarnation - ) { - return false - } - if (!holders?.delete(holderId) || holders.size > 0) { - return false - } - this.bySession.delete(sessionId) - return true - } - - isHeld(sessionId: string): boolean { - return (this.bySession.get(sessionId)?.size ?? 0) > 0 - } - - has(sessionId: string, holderId: string): boolean { - return this.bySession.get(sessionId)?.has(holderId) ?? false - } - - incarnation(sessionId: string, holderId: string): symbol | undefined { - return this.bySession.get(sessionId)?.get(holderId)?.incarnation - } - - holderIds(sessionId: string): string[] { - return [...(this.bySession.get(sessionId)?.keys() ?? [])] - } - - hasResumeCapableHolder(sessionId: string): boolean { - return [...(this.bySession.get(sessionId)?.values() ?? [])].some( - (holder) => holder.resumeCapable - ) - } - - /** Drops every holder of one session without evaluating the edge, for a session that is gone. */ - forget(sessionId: string): void { - this.bySession.delete(sessionId) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts deleted file mode 100644 index 822f36df5bf..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -// The parts a session's lifetime is assembled from: the holder set, the release clock, and the -// deadline that keeps teardown from hanging. - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' -import { StructuredAgentSessionHolders } from './structured-agent-session-holders' -import { StructuredAgentSessionReleaseClock } from './structured-agent-session-release-clock' -import { StructuredAgentSessionHolds } from './structured-agent-session-holds' -import { - STRUCTURED_AGENT_SESSION_EVICTION_STEPS, - evictStructuredAgentSession -} from './structured-agent-session-eviction' -import { - StructuredAgentSessionEvictionTimeoutError, - withStructuredAgentSessionEvictionDeadline -} from './structured-agent-session-eviction-deadline' - -const clocks: StructuredAgentSessionReleaseClock[] = [] - -/** The host's per-session queue, so a hold and a writer really take turns. */ -function keyedSerialize() { - const chains = new Map>() - return (sessionId: string, task: () => Promise) => - runKeyedSerializedOperation(chains, sessionId, task) -} - -function clock(deps: { - hasOwedWork?: () => boolean - isHeld?: () => boolean - evict: (sessionId: string) => Promise - onError?: (input: { sessionId: string; error: unknown }) => void -}): StructuredAgentSessionReleaseClock { - const created = new StructuredAgentSessionReleaseClock({ - hasOwedWork: deps.hasOwedWork ?? (() => false), - isHeld: deps.isHeld ?? (() => false), - evict: deps.evict, - ...(deps.onError ? { onError: deps.onError } : {}), - graceMs: 1 - }) - clocks.push(created) - return created -} - -afterEach(() => { - for (const created of clocks.splice(0)) { - created.dispose() - } -}) - -describe('the holder set', () => { - it('reports the first and last holder, and nothing in between', () => { - const holders = new StructuredAgentSessionHolders() - - expect(holders.add('session-1', 'a')).toBe(true) - expect(holders.add('session-1', 'b')).toBe(false) - expect(holders.remove('session-1', 'a')).toBe(false) - expect(holders.remove('session-1', 'b')).toBe(true) - expect(holders.isHeld('session-1')).toBe(false) - }) - - // The reason this is a set and not a count: every release path can fire twice or not at all. - it('absorbs a duplicate hold and a duplicate release', () => { - const holders = new StructuredAgentSessionHolders() - - holders.add('session-1', 'a') - holders.add('session-1', 'a') - expect(holders.remove('session-1', 'a')).toBe(true) - expect(holders.remove('session-1', 'a')).toBe(false) - expect(holders.holderIds('session-1')).toEqual([]) - }) - - it('keeps one session holders out of another session holders', () => { - const holders = new StructuredAgentSessionHolders() - - holders.add('session-1', 'a') - holders.add('session-2', 'a') - holders.remove('session-1', 'a') - - expect(holders.isHeld('session-1')).toBe(false) - expect(holders.isHeld('session-2')).toBe(true) - }) - - it('distinguishes retaining holders from holders that may resume a provider', () => { - const holders = new StructuredAgentSessionHolders() - - holders.add('session-1', 'subscriber', false) - expect(holders.hasResumeCapableHolder('session-1')).toBe(false) - - holders.add('session-1', 'chat', true) - expect(holders.hasResumeCapableHolder('session-1')).toBe(true) - holders.remove('session-1', 'chat') - expect(holders.hasResumeCapableHolder('session-1')).toBe(false) - }) -}) - -describe('the release clock', () => { - it('waits out a running turn instead of evicting into it', async () => { - const evict = vi.fn(async () => {}) - let turnRunning = true - const releasing = clock({ hasOwedWork: () => turnRunning, evict }) - - releasing.arm('session-1') - await new Promise((resolve) => setTimeout(resolve, 30)) - expect(evict).not.toHaveBeenCalled() - - turnRunning = false - await vi.waitFor(() => expect(evict).toHaveBeenCalledWith('session-1')) - }) - - it('stands down when a holder arrives during the wait', async () => { - const evict = vi.fn(async () => {}) - const releasing = clock({ isHeld: () => true, evict }) - - releasing.arm('session-1') - await new Promise((resolve) => setTimeout(resolve, 30)) - - expect(evict).not.toHaveBeenCalled() - }) - - it('keeps an idle unheld child for thirty minutes, and activity starts the window over', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - try { - const evict = vi.fn(async () => {}) - const idle = new StructuredAgentSessionReleaseClock({ - hasOwedWork: () => false, - isHeld: () => false, - evict - }) - clocks.push(idle) - const minutes = (count: number) => vi.advanceTimersByTimeAsync(count * 60_000) - - idle.arm('session-1') - await minutes(20) - idle.renew('session-1') - await minutes(29) - expect(evict).not.toHaveBeenCalled() - - await minutes(1) - expect(evict).toHaveBeenCalledWith('session-1') - } finally { - vi.useRealTimers() - } - }) - - it('does not start a window for a session nothing released', async () => { - const evict = vi.fn(async () => {}) - const releasing = clock({ evict }) - - releasing.renew('session-1') - await new Promise((resolve) => setTimeout(resolve, 30)) - - expect(releasing.isArmed('session-1')).toBe(false) - expect(evict).not.toHaveBeenCalled() - }) - - it('reports a failed eviction rather than swallowing it', async () => { - const onError = vi.fn() - const releasing = clock({ - evict: async () => { - throw new Error('child would not stop') - }, - onError - }) - - releasing.arm('session-1') - - await vi.waitFor(() => - expect(onError).toHaveBeenCalledWith({ - sessionId: 'session-1', - error: expect.objectContaining({ message: 'child would not stop' }) - }) - ) - }) -}) - -describe('holds', () => { - it('resumes a session on its first hold and not on a retained one', async () => { - let child = false - const resume = vi.fn(async () => { - child = true - return { ok: true as const } - }) - const holds = new StructuredAgentSessionHolds({ - resume, - serialize: keyedSerialize(), - hasProviderChild: () => child, - lastStartFailed: () => false, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 1 - }) - - await holds.hold('session-1', 'stream-1', { resume: false }) - expect(resume).not.toHaveBeenCalled() - - await holds.hold('session-1', 'chat-1') - expect(resume).toHaveBeenCalledOnce() - - child = true - await holds.hold('session-1', 'chat-2') - expect(resume).toHaveBeenCalledOnce() - holds.dispose() - }) - - it('runs one resume for a writer and a hold that ask in the same gap', async () => { - const gate = Promise.withResolvers() - let child = false - const resume = vi.fn(async () => { - await gate.promise - child = true - return { ok: true as const } - }) - const serialize = keyedSerialize() - const holds = new StructuredAgentSessionHolds({ - resume, - serialize, - hasProviderChild: () => child, - lastStartFailed: () => false, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 1 - }) - - // A send's ensure-owner step: already inside the session's serialize when it asks. - const writer = serialize('session-1', () => holds.ensureProviderChild('session-1')) - const hold = holds.hold('session-1', 'chat-1') - gate.resolve() - - await expect(writer).resolves.toEqual({ ok: true }) - await hold - // The hold ran after the writer's step and found the child: nothing to resume. - expect(resume).toHaveBeenCalledOnce() - // The surface arrived while the writer's resume ran, so the child it got is held, not idle. - expect(holds.isHeld('session-1')).toBe(true) - expect(holds.isReleasePending('session-1')).toBe(false) - holds.dispose() - }) - - it('puts a child a writer resumed with no surface on the idle clock', async () => { - let child = false - const serialize = keyedSerialize() - const holds = new StructuredAgentSessionHolds({ - resume: async () => { - child = true - return { ok: true as const } - }, - serialize, - hasProviderChild: () => child, - lastStartFailed: () => false, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 60_000 - }) - - await serialize('session-1', () => holds.ensureProviderChild('session-1')) - - expect(holds.isHeld('session-1')).toBe(false) - expect(holds.isReleasePending('session-1')).toBe(true) - holds.dispose() - }) - - it('never arms the clock for a session with nothing to stop', async () => { - const evict = vi.fn(async () => {}) - const holds = new StructuredAgentSessionHolds({ - resume: async () => ({ ok: true as const }), - serialize: keyedSerialize(), - hasProviderChild: () => false, - lastStartFailed: () => false, - hasOwedWork: () => false, - evict, - graceMs: 1 - }) - - await holds.hold('session-1', 'chat-1', { resume: false }) - holds.release('session-1', 'chat-1') - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(evict).not.toHaveBeenCalled() - expect(holds.isReleasePending('session-1')).toBe(false) - holds.dispose() - }) - - it('fails a write-capable hold when resume proves no provider child', async () => { - const holds = new StructuredAgentSessionHolds({ - resume: async () => ({ ok: true as const }), - serialize: keyedSerialize(), - hasProviderChild: () => false, - lastStartFailed: () => false, - hasOwedWork: () => false, - evict: async () => {}, - graceMs: 1 - }) - - await expect(holds.hold('session-1', 'chat-1')).rejects.toThrow( - 'agent_session_ownership_unknown' - ) - expect(holds.isHeld('session-1')).toBe(false) - holds.dispose() - }) -}) - -describe('the teardown deadline', () => { - it('leaves the child loaded instead of forcing it, and keeps the session indexed', async () => { - const acknowledgeRelease = vi.fn() - const releaseLease = vi.fn(async () => {}) - - await expect( - evictStructuredAgentSession( - { - sessionId: 'session-1', - eventSink: { - unbind: vi.fn(), - drained: vi.fn(async () => {}), - close: vi.fn() - } as never, - adapter: { closeSession: () => new Promise(() => {}) } as never, - acknowledgeRelease, - discardSink: vi.fn(), - releaseLease - }, - withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS, 5) - ) - ).rejects.toMatchObject({ step: 'stop-provider-child' }) - - expect(acknowledgeRelease).not.toHaveBeenCalled() - expect(releaseLease).not.toHaveBeenCalled() - }) - - it('names the step that ran out of time', async () => { - const [step] = withStructuredAgentSessionEvictionDeadline( - [{ name: 'slow-step', run: () => new Promise(() => {}) }], - 5 - ) - - await expect(step?.run({} as never)).rejects.toBeInstanceOf( - StructuredAgentSessionEvictionTimeoutError - ) - }) - - it('does not delay a step that finishes', async () => { - const ran: string[] = [] - const steps = withStructuredAgentSessionEvictionDeadline( - [{ name: 'fast-step', run: () => void ran.push('fast-step') }], - 5_000 - ) - - await steps[0]?.run({} as never) - - expect(ran).toEqual(['fast-step']) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts deleted file mode 100644 index 89f28b890ed..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts +++ /dev/null @@ -1,185 +0,0 @@ -// The lifetime of a structured session, tied to the surfaces that want one. -// -// Nothing used to tell the host that a chat WANTED a session, and nothing told it when a chat -// stopped wanting one. Both halves of that gap cost real processes: sessions nobody had opened got -// an app-server at every launch, and sessions the user closed kept theirs until the app quit. -// -// A surface takes a hold when it binds and drops it when it goes away. The first hold on a session -// with no child resumes it — that, and not the shape of a lease on disk, is what makes a provider -// process exist — unless that session's last start died starting: only a send retries one. The last hold leaving starts the idle release clock. Transport close is the BACKSTOP, -// not the mechanism: a client that vanishes mid-flight never sends its release, so the caller -// registers one against the connection and the holder set absorbs the duplicate. -// -// A send to a childless session resumes it too, and so does provider-exit recovery under an open -// surface. All three go through `ensureProviderChild` inside the session's serialize, so they take -// turns: the first to run attaches, and the next finds the child and attaches nothing. Two attaches -// for one session would race against the same released fence, and the loser's stale fence refused -// it — a hold that lost dropped its holder, a send that lost was refused. - -import { - StructuredAgentSessionReleaseClock, - type StructuredAgentSessionReleaseClockDeps -} from './structured-agent-session-release-clock' -import { StructuredAgentSessionHolders } from './structured-agent-session-holders' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' -import type { StructuredAgentSessionAttachOptions } from './structured-agent-session-attach-orchestration' - -export type StructuredAgentSessionHoldsDeps = { - /** Attaches a provider child, for a caller already inside `serialize`. */ - resume: ( - sessionId: string, - attachOptions?: StructuredAgentSessionAttachOptions - ) => Promise - serialize: (sessionId: string, task: () => Promise) => Promise - /** Whether evicting this session would actually free anything. */ - hasProviderChild: (sessionId: string) => boolean - /** The last start died starting; a surface does not retry it, the next send does. */ - lastStartFailed: (sessionId: string) => boolean - hasOwedWork: (sessionId: string) => boolean - evict: (sessionId: string) => Promise - onError?: (input: { sessionId: string; error: unknown }) => void - graceMs?: number -} - -export type StructuredAgentSessionHoldOptions = { - /** False for a hold that only RETAINS — a subscription stream, which must not make a child - * exist just by reading history. */ - resume?: boolean -} - -export class StructuredAgentSessionHolds { - private readonly holders = new StructuredAgentSessionHolders() - private readonly clock: StructuredAgentSessionReleaseClock - private disposed = false - - constructor(private readonly deps: StructuredAgentSessionHoldsDeps) { - const clockDeps: StructuredAgentSessionReleaseClockDeps = { - hasOwedWork: deps.hasOwedWork, - isHeld: (sessionId) => this.holders.isHeld(sessionId), - evict: (sessionId) => this.deps.evict(sessionId), - ...(deps.onError ? { onError: deps.onError } : {}), - ...(deps.graceMs === undefined ? {} : { graceMs: deps.graceMs }) - } - this.clock = new StructuredAgentSessionReleaseClock(clockDeps) - } - - async hold( - sessionId: string, - holderId: string, - options: StructuredAgentSessionHoldOptions = {} - ): Promise { - const alreadyHeld = this.holders.has(sessionId, holderId) - this.holders.add(sessionId, holderId, options.resume !== false) - const incarnation = this.holders.incarnation(sessionId, holderId) - // Unconditional, not only on the first-holder edge: a second surface arriving during the grace - // window must cancel the pending release too. - this.clock.cancel(sessionId) - if (options.resume === false) { - return - } - let resumed: StructuredAgentSessionResumeOutcome - try { - resumed = await this.deps.serialize(sessionId, () => - this.deps.lastStartFailed(sessionId) - ? Promise.resolve({ ok: true as const }) - : this.ensureProviderChild(sessionId) - ) - } catch (error) { - this.releaseFailedHold(sessionId, holderId, alreadyHeld, incarnation) - throw error - } - if (!resumed.ok) { - this.releaseFailedHold(sessionId, holderId, alreadyHeld, incarnation) - // The RPC surface raises a refusal as its code. - throw new Error(resumed.refusal.code) - } - } - - /** Only the holder this call added, at the incarnation it added: a same-ID hold that left and - * came back while this one waited owns the holder now, and its own attempt decides it. */ - private releaseFailedHold( - sessionId: string, - holderId: string, - alreadyHeld: boolean, - incarnation: symbol | undefined - ): void { - if (!alreadyHeld && incarnation !== undefined) { - this.release(sessionId, holderId, incarnation) - } - } - - /** - * Gives the session a provider child if it has none. - * - * For a caller already inside the session's serialize, which is what makes "if it has none" - * exact: a hold and a send that both find the owner gone run this in turn, and the second sees - * the first one's child. Each caller makes at most one attach, and a failed one leaves the - * next caller to make its own. With no surface holding the session afterwards, the child goes - * on the same clock a departed surface would start — including the surface that held it when - * provider-exit recovery began and left while the attach ran. - */ - async ensureProviderChild( - sessionId: string, - attachOptions?: StructuredAgentSessionAttachOptions - ): Promise { - if (this.deps.hasProviderChild(sessionId)) { - return { ok: true } - } - const resumed = await this.deps.resume(sessionId, attachOptions) - if (!resumed.ok) { - return resumed - } - if (!this.deps.hasProviderChild(sessionId)) { - return { - ok: false, - refusal: { - code: 'agent_session_ownership_unknown', - message: 'The session attached without a provider child to write to.' - } - } - } - // The last surface can disconnect before acquisition makes a child available to release. - if (!this.disposed && !this.holders.isHeld(sessionId)) { - this.clock.arm(sessionId) - } - return { ok: true } - } - - /** Activity — a journal write, or a start reaching the work it held; only a pending release - * notices, and it restarts its full window. */ - renew(sessionId: string): void { - this.clock.renew(sessionId) - } - - release(sessionId: string, holderId: string, expectedIncarnation?: symbol): void { - if (!this.holders.remove(sessionId, holderId, expectedIncarnation)) { - return - } - if (!this.disposed && this.deps.hasProviderChild(sessionId)) { - this.clock.arm(sessionId) - } - } - - /** Drops the holders of a session that is gone, whoever evicted it. */ - forget(sessionId: string): void { - this.clock.cancel(sessionId) - this.holders.forget(sessionId) - } - - isHeld(sessionId: string): boolean { - return this.holders.isHeld(sessionId) - } - - hasResumeCapableHolder(sessionId: string): boolean { - return this.holders.hasResumeCapableHolder(sessionId) - } - - isReleasePending(sessionId: string): boolean { - return this.clock.isArmed(sessionId) - } - - dispose(): void { - this.disposed = true - this.clock.dispose() - } -} 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 dab9ea639c6..dace93edc62 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,13 +11,13 @@ import { type StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-hold-resume' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredAgentSessionStartFailureText } from './structured-agent-session-send-preparation' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { settleInterruptedCompaction } from './structured-compaction-recovery' import { recoverStructuredRewind } from './structured-rewind-recovery' @@ -40,7 +40,11 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessions: Map serialize: (sessionId: string, task: () => Promise) => Promise trackStart: (start: Promise) => Promise - ensureProviderChild: (sessionId: string) => Promise + /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ + ensureProviderChild: ( + sessionId: string, + startedFor: string + ) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void publishRestored: (sessionId: string) => void }): StructuredAgentSessionConversationDelivery { @@ -53,8 +57,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { ensureProviderChild: input.ensureProviderChild, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), - startFailureText: (sessionId, cause) => - structuredAgentSessionStartFailureText(deps.store.getRecord(sessionId), cause), + failureTextContext: (sessionId) => + structuredAgentSessionFailureWordsContext(deps.store.getRecord(sessionId)), onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) const adoptOpened = async ( @@ -83,8 +87,8 @@ 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 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. + * 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. */ async function settleInterruptedCommands( deps: StructuredAgentSessionHostDeps, 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 49d8b24e644..c9ba8714b4e 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 @@ -1,21 +1,22 @@ -// The host's half of a session's lifetime: what a close does, and what a hold is wired to. +// The host's half of a session's lifetime: stopping its agent, and closing its conversation. // -// Lifted out of the host for the same reason attaching was — the host is a coordinator, and the -// sequence that stops a provider child and hands its lease back reads better next to the holder -// bookkeeping that decides when to run it than buried among the twenty other things a session can -// do. +// 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. +// +// 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. -import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { DISPATCH_REJECTED_PROVIDER_CLOSED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { evictStructuredAgentSession, STRUCTURED_AGENT_SESSION_EVICTION_STEPS, type StructuredAgentSessionEvictionContext } from './structured-agent-session-eviction' import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline' -import { StructuredAgentSessionHolds } from './structured-agent-session-holds' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionChildEndCause, @@ -25,12 +26,9 @@ import type { } from './structured-agent-session-host-types' import { endProviderChild, - failedProviderChildStart, structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' -import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume' -import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' export type StructuredAgentSessionLifetimeContext = { @@ -38,8 +36,6 @@ export type StructuredAgentSessionLifetimeContext = { runtimeState: StructuredAgentSessionHostRuntimeState sessions: Map now: () => number - /** Drops the session's row from the agent-status store; see `forgetStructuredAgentSession`. */ - forgetStatus: (sessionId: string) => void /** Re-projects the session's status after its agent stopped and the chat stays. */ publishStatus?: (sessionId: string) => void /** Quit-only snapshot taken immediately before the provider child is stopped. */ @@ -64,35 +60,11 @@ export async function abandonQueuedStructuredAgentSessionMessages( await journal .rejectQueuedSubmissions( structuredAgentSessionConversationFence(deps.store, sessionId), - DISPATCH_REJECTED_PROVIDER_CLOSED + agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) ) .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) } -/** Dropping a session and dropping its status row are ONE operation: the store keeps the row until - * told, so a caller that only deletes strands a live-looking row no reader can ever decay. */ -export async function forgetStructuredAgentSession( - context: Pick & { - deps: ConversationCloseDeps - }, - sessionId: string -): Promise { - const session = context.sessions.get(sessionId) - if (session) { - await abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) - } - await session?.journal.close() - context.sessions.delete(sessionId) - context.forgetStatus(sessionId) -} - -function hasProviderChild( - context: StructuredAgentSessionLifetimeContext, - sessionId: string -): boolean { - return (context.sessions.get(sessionId)?.child ?? null) !== null -} - /** The wind-down this host owes for the session's child. A live child always owes one, whatever a * previous childless eviction recorded: a remembered tombstone must never outrank the child in * front of it. */ @@ -108,7 +80,7 @@ function owedProviderChildWindDown( * The agent goes to rest; the conversation stays. Runs the eviction steps under a deadline. A step * that fails — or runs out of time — aborts the rest and leaves the wind-down owed, so the next * stop is a real retry. `ending` is how the child's end is told: a user's Stop, the host stopping it - * for a cause (with its text), or an eviction whose close forgets the conversation next. + * for a cause (with its text), or an eviction the conversation's close follows. */ export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, @@ -172,7 +144,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( } }) if (!settled) { - // Without the cause the quit log names the step and nothing else. + // Without the cause the log names the step and nothing else. throw new Error('dead generation work settlement failed', { cause: settlementError }) } }, @@ -187,11 +159,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( }) } session.owesProviderChildWindDown = undefined - if (ending.cause === 'evict') { - context.forgetStatus(sessionId) - return - } - // The conversation stays: its readers keep their own fence, and only the status moves. + // Whatever ended the child, the row belongs to the conversation: it shows not-running, and + // only the conversation's close forgets it. context.publishStatus?.(sessionId) } } @@ -201,17 +170,39 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ) } -/** Ends the conversation's resources, not the conversation: its child stops, and then its handle - * closes and it leaves the map. A stop that fails throws first, leaving it indexed for a retry. */ -export async function evictHeldStructuredAgentSession( - context: StructuredAgentSessionLifetimeContext, +/** Whether the conversation's handle is only a cache now: no child, no wind-down owed, and nothing + * queued or waiting on the provider. */ +export function structuredAgentSessionConversationClosable( + session: StructuredAgentSessionHostSession +): boolean { + return ( + owedProviderChildWindDown(session) === undefined && + !session.journal.submissions().some(isQueuedAgentJournalSubmission) && + session.journal.pendingSubmissions().length === 0 + ) +} + +/** + * 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. + */ +export async function closeStructuredAgentSessionConversationUnderSerialize( + context: Pick & { + /** The status row outlives the handle; see `StructuredAgentSessionClientDelivery`. */ + closeStatus: (sessionId: string) => void + }, sessionId: string -): Promise { - if (!context.sessions.has(sessionId)) { - return +): Promise { + const session = context.sessions.get(sessionId) + if (!session || !structuredAgentSessionConversationClosable(session)) { + return false } - await stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict' }) - await forgetStructuredAgentSession(context, sessionId) + context.sessions.delete(sessionId) + context.closeStatus(sessionId) + await session.journal.close() + return true } /** Stops every provider child owned by this host while keeping failed evictions reachable. A @@ -226,7 +217,7 @@ export async function evictOwnedStructuredAgentSessions( const ownedSessionIds = [...context.sessions] .filter(([, session]) => owedProviderChildWindDown(session) !== undefined) .map(([sessionId]) => sessionId) - // Retained up front and cleared only once an eviction settles: the quit phase is bounded, and a + // Retained up front and cleared only once a stop settles: the quit phase is bounded, and a // timeout leaves these still running. Closing their journals underneath them is the one outcome // the retain set exists to prevent. for (const sessionId of ownedSessionIds) { @@ -237,7 +228,7 @@ export async function evictOwnedStructuredAgentSessions( ownedSessionIds.map(async (sessionId) => { try { await context.serialize(sessionId, () => - evictHeldStructuredAgentSession(context, sessionId) + stopStructuredAgentSessionAgentUnderSerialize(context, sessionId) ) retainOnFailure.delete(sessionId) } catch (error) { @@ -249,55 +240,3 @@ export async function evictOwnedStructuredAgentSessions( throw new AggregateError(failures, 'structured agent-session child eviction failed') } } - -/** The holds resume through the host's own attach, inside the session's serialize: a hold's - * resume and a send's ensure-owner step are the same serialized attach with a different asker. */ -export function createStructuredAgentSessionHolds( - attachContext: () => StructuredAgentSessionAttachContext, - close: (sessionId: string) => Promise, - deliveryActive: (sessionId: string) => boolean -): StructuredAgentSessionHolds { - const context = attachContext() - return new StructuredAgentSessionHolds({ - resume: (sessionId, attachOptions) => - resumeHeldStructuredAgentSession({ - sessionId, - context: attachContext(), - callerKey: attachOptions?.admitRecoveryTicket - ? 'trusted-local:provider-exit-recovery' - : 'trusted-local:surface-hold', - ...(attachOptions ? { attachOptions } : {}) - }), - // Tracked from enqueue: a quit drains a queued resume before it evicts, so no child is - // spawned behind the eviction and orphaned. - serialize: (sessionId, task) => { - const current = attachContext() - return current.tasks.trackAttach(current.serialize(sessionId, task)) - }, - evict: close, - hasProviderChild: (sessionId) => hasProviderChild(context, sessionId), - lastStartFailed: (sessionId) => { - const session = context.sessions.get(sessionId) - return session !== undefined && failedProviderChildStart(session) !== null - }, - // A message accepted and not yet handed over is owed to this child, and so is one pending while - // the child still starts. Any other pending send may wait on an echo that never comes, so - // eviction retires it. Subagents, commands and monitors outlive the lead's turn inside the - // child, so the live roster the sidebar shows as working is owed too; stopping the child would - // end them silently. - hasOwedWork: (sessionId) => { - const session = context.sessions.get(sessionId) - return session - ? activeStructuredAgentSessionTurnId(session.journal.snapshot().items) !== null || - deliveryActive(sessionId) || - session.journal.submissions().some(isQueuedAgentJournalSubmission) || - (session.child?.phase === 'starting' && - session.journal.pendingSubmissions().length > 0) || - agentChildWorkLiveness(context.deps.adapter.backgroundTaskState?.(sessionId)?.tasks) !== - null - : false - }, - onError: (error) => context.deps.onEventSinkError?.(error), - ...(context.deps.releaseGraceMs === undefined ? {} : { graceMs: context.deps.releaseGraceMs }) - }) -} 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 9decd5a488a..35782b676c2 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 @@ -1,10 +1,11 @@ -// Everything a client can ask an ATTACHED session to do: send a turn, cancel one, answer a prompt, -// change an option, read the options back. +// Everything a client can ask a session to do: send a turn, cancel one, answer a prompt, change an +// option, read the options back. // // They share one shape — admit the envelope against the lease, run a plan, publish the journal — so -// they share one path here rather than five copies in the host. The host keeps attach, holds and -// teardown. A send and a Stop are conversation writes: they open the conversation and are admitted -// without the writer lease; the session's delivery loop starts the provider child a send needs. +// they share one path here rather than five copies in the host. The host keeps attach and teardown. +// Each opens the conversation first. A send, a Stop and an option pick are conversation writes, +// admitted without the writer lease; the delivery loop starts the provider child a send needs, and +// an operation only the provider can perform starts it before admission. import type { AgentJournalItemIdentity, @@ -15,22 +16,28 @@ import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, AgentSessionOptionResult, - AgentSessionOptionsResult, AgentSessionPromptResult, AgentSessionSendResult, AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection +} from '../../../shared/agent-session-failure-words' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { threadGoalPlan } from './structured-agent-session-thread-goal' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { admitAndRunAgentSessionMutation, - type AgentSessionMutationRequest + type AgentSessionMutationRequest, + type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' import { - openConversationForWrite, + openForWrite, + openWithAgent, + sendPreparation, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { @@ -45,16 +52,23 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + readStructuredAgentSessionOptions, + recordStructuredAgentSessionOptionIntent +} from './structured-agent-session-options-read' export type StructuredAgentSessionMutationContext = { deps: StructuredAgentSessionHostDeps sessions: Map publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void flushStreamedEvents: (sessionId: string) => Promise - requireSession: (sessionId: string) => StructuredAgentSessionHostSession + /** The host's accessor, for a caller outside the session's serialize. */ + conversation: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise /** The session's conversation, opened when closed; inside the caller's serialize. */ openConversation: (sessionId: string) => Promise + /** Gives the session a provider child; inside the caller's serialize. */ + ensureAgent: (sessionId: string) => Promise /** A message was accepted: the session's delivery loop hands it over. */ wakeDelivery: (sessionId: string) => void /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ @@ -115,7 +129,7 @@ export function sendStructuredAgentSessionTurn( return accepted } }, - () => openConversationForWrite(context.openConversation, params.envelope) + sendPreparation(context, params.envelope) ) } @@ -142,7 +156,13 @@ export function cancelStructuredAgentSessionTurn( : context const plan = cancelPlan(params) if (params.scope || params.prompt) { - return mutate(cancellationContext, caller, params.envelope, plan) + return mutate( + cancellationContext, + caller, + params.envelope, + plan, + openForWrite(context, params.envelope) + ) } return mutate( cancellationContext, @@ -154,7 +174,7 @@ export function cancelStructuredAgentSessionTurn( // Stop withdraws every queued message first, whatever the start or the child is doing. const withdrawn = await ctx.journal.rejectQueuedSubmissions( ctx.fence, - DISPATCH_REJECTED_CANCELLED + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) ) const child = context.sessions.get(ctx.sessionId)?.child if (child?.phase === 'starting') { @@ -167,7 +187,7 @@ export function cancelStructuredAgentSessionTurn( : { ok: true, value: { turnId: params.turnId, cancelled: withdrawn.length > 0 } } } }, - () => openConversationForWrite(context.openConversation, params.envelope) + openForWrite(context, params.envelope) ) } @@ -176,7 +196,13 @@ export function respondToStructuredAgentSessionPrompt( caller: StructuredAgentSessionCaller, params: AgentSessionPromptRequest & { envelope: AgentSessionMutationEnvelope } ): Promise> { - return mutate(context, caller, params.envelope, promptPlan(params)) + return mutate( + context, + caller, + params.envelope, + promptPlan(params), + openForWrite(context, params.envelope) + ) } export async function setStructuredAgentSessionOption( @@ -186,7 +212,26 @@ export async function setStructuredAgentSessionOption( ): Promise> { // Outside the queue: a pick made while the provider starts then queues behind what its start persists. await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId) - return mutate(context, caller, params.envelope, setOptionPlan(params)) + const plan = setOptionPlan(params) + const atRest = () => !context.sessions.get(params.envelope.sessionId)?.child + return mutate( + context, + caller, + params.envelope, + { + ...plan, + // Read as the call is admitted: with no child running, the pick is a conversation write — + // intent the next start replays. A running child's pick is still its owner's to make. + get conversationWrite() { + return atRest() ? (true as const) : undefined + }, + run: (ctx) => + atRest() + ? recordStructuredAgentSessionOptionIntent(context.deps.store, ctx, params) + : plan.run(ctx) + }, + openForWrite(context, params.envelope) + ) } export function changeStructuredAgentSessionThreadGoal( @@ -194,43 +239,13 @@ export function changeStructuredAgentSessionThreadGoal( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange } ): Promise> { - return mutate(context, caller, params.envelope, threadGoalPlan(params)) -} - -export function readStructuredAgentSessionOptions( - context: StructuredAgentSessionMutationContext, - sessionId: string -): Promise { - return context.serialize(sessionId, async () => { - const session = context.requireSession(sessionId) - if (!context.deps.adapter.readOptions) { - throw new Error('structured_agent_session_options_unsupported') - } - const options = await context.deps.adapter.readOptions({ - sessionId, - fence: - session.child?.fence ?? - structuredAgentSessionConversationFence(context.deps.store, sessionId) - }) - return { - ...options, - rewind: - context.deps.store.getRecord(sessionId)?.rewind?.phase === 'prepared' || - context.deps.store.getRecord(sessionId)?.rewind?.phase === 'provider-succeeded' - ? { supported: false, reason: 'outcome-unknown' } - : (context.deps.adapter.rewindSupport?.(sessionId) ?? { - supported: false, - reason: 'unsupported' - }), - conversationCommands: context.deps.adapter.compact ? ['clear', 'compact'] : ['clear'], - ...(context.deps.adapter.supportsThreadGoal?.(sessionId) - ? { threadGoal: { current: session.journal.threadGoal() } } - : {}), - ...(context.deps.adapter.recordsContextUsage?.(sessionId) - ? { contextUsage: { current: session.journal.contextUsage() } } - : {}) - } - }) + return mutate( + context, + caller, + params.envelope, + threadGoalPlan(params), + openWithAgent(context, params.envelope) + ) } /** Settle provider-proven delivery independently of an in-flight client mutation. */ @@ -239,7 +254,10 @@ export async function settleStructuredAgentSessionLateDispatch( input: { sessionId: string clientMessageId: string - } & ({ providerIdentity: AgentJournalItemIdentity } | { state: 'rejected'; reason: string }) + } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + ) ): Promise { const session = context.sessions.get(input.sessionId) if (!session) { @@ -259,51 +277,12 @@ export async function settleStructuredAgentSessionLateDispatch( clientMessageId: input.clientMessageId, state: 'rejected', reason: input.reason, + rejection: input.rejection, fence } ) } -/** - * Releases sends the provider can no longer be holding. - * - * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of - * non-delivery — and a live `unknown` reads as work still owed, so the session - * shows working until something re-derives it. The provider reporting its thread - * not running, with no turn open, IS that re-derivation. - * - * `pending` is deliberately untouched: that send's dispatch has not returned yet - * and may be in flight right now. And `recovered` only retires the obligation — - * it never makes a send re-deliverable, because the provider may well have run it. - */ -export async function releaseStructuredAgentSessionUnansweredDispatches( - context: Pick & { - deps: { store: Pick } - }, - input: { sessionId: string; reason: string } -): Promise { - const session = context.sessions.get(input.sessionId) - if (!session) { - return - } - const stranded = session.journal - .submissions() - .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true) - if (stranded.length === 0) { - return - } - for (const entry of stranded) { - await session.journal.resolveDispatch({ - clientMessageId: entry.clientMessageId, - state: 'unknown', - // The earlier reason names a sharper fact than this one does. - reason: entry.reason ?? input.reason, - fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId), - recovered: true - }) - } -} - /** The host's thin mutation surface. Each call re-reads the context, so a session * map or fence that moves between calls is never captured by a stale closure. */ export function structuredAgentSessionMutationDelegates( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index 39fe7c3fc22..63f37a37590 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -34,8 +34,9 @@ export class StructuredAgentSessionHostRuntimeState { this.leaseRenewer.start() } - stopLeaseRenewal(): void { - this.leaseRenewer.stop() + /** Resolves once a renewal tick already in flight has finished writing. */ + stopLeaseRenewal(): Promise { + return this.leaseRenewer.stop() } /** The sink the session's current child writes through, created on first use. */ 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 d04e17d2d26..5fd0440a1fe 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 @@ -5,7 +5,7 @@ describe('structured agent-session host teardown', () => { it('names every phase, so the quit-path order is pinned rather than incidental', () => { const noop = async (): Promise => undefined const phases = structuredAgentSessionHostTeardownPhases({ - holds: { dispose: noop }, + idleSweep: { dispose: noop }, runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, @@ -14,7 +14,7 @@ describe('structured agent-session host teardown', () => { }) expect(phases.map((phase) => phase.name)).toEqual([ 'begin-resume-markers', - 'dispose-holds', + 'dispose-idle-sweep', 'stop-lease-renewal', 'drain-attaches', 'evict-owned-sessions', @@ -30,7 +30,7 @@ describe('structured agent-session host teardown', () => { const cleaned = vi.fn(async () => {}) const flush = vi.fn(async () => cleaned()) const phases = structuredAgentSessionHostTeardownPhases({ - holds: { dispose: cleaned }, + idleSweep: { dispose: cleaned }, runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush }, tasks: { drainAttaches: cleaned }, evictOwnedSessions: cleaned, 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 2f1b0f3c287..97a27d5c246 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 @@ -47,9 +47,9 @@ async function withPhaseTimeout(run: () => Promise, timeoutMs: number): Pr /** The quit-path phase order, which is load-bearing rather than incidental. */ export function structuredAgentSessionHostTeardownPhases(collaborators: { - holds: { dispose: () => Promise | void } + idleSweep: { dispose: () => Promise | void } runtimeState: { - stopLeaseRenewal: () => void + stopLeaseRenewal: () => Promise | void flushAllEventSinks: () => Promise } tasks: { drainAttaches: () => Promise } @@ -69,7 +69,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { } } }, - { name: 'dispose-holds', run: () => collaborators.holds.dispose() }, + { name: 'dispose-idle-sweep', run: () => collaborators.idleSweep.dispose() }, { name: 'stop-lease-renewal', run: () => collaborators.runtimeState.stopLeaseRenewal() }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { @@ -141,7 +141,7 @@ export async function tearDownStructuredAgentSessionHost(input: { export async function flushStructuredAgentSessionHost( context: StructuredAgentSessionLifetimeContext & - Pick[0], 'holds' | 'tasks'> & { + Pick[0], 'idleSweep' | 'tasks'> & { restartResume: StructuredAgentSessionRestartResume serialize: (sessionId: string, task: () => Promise) => Promise trigger: AgentSessionResumeTrigger 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 new file mode 100644 index 00000000000..b069fecd1bb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -0,0 +1,123 @@ +// The helper is what the restart specs remove their temp directory behind, so what it waits for is +// load-bearing: a store commit that lands afterwards re-creates the directory it just removed. + +import { existsSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +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 type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from './structured-agent-session-host-test-data' + +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' + }) + // 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 + let openGate = (): void => {} + let reportEntered = (): void => {} + const entered = new Promise((resolve) => { + reportEntered = resolve + }) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence }) => { + if (gate) { + reportEntered() + await gate + } + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }, + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + releaseAcquisition: async () => true, + setOption: async () => undefined + } + const host = new StructuredAgentSessionHost({ + store, + adapter, + probeOwner: async () => ({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + // The conversation stays and its provider child does not, so the next send makes the delivery + // loop start one — the shape the refusal-oracle spec ends on. + await host.close(SESSION) + gate = new Promise((resolve) => { + openGate = resolve + }) + + const body = hostTestMessage('delivery loop') + expect( + await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${NOW}-000000000000000000000000000003e9`, + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + ).toMatchObject({ ok: true }) + await entered + + let abandoned = false + const abandoning = abandonStructuredAgentSessionHost(host).then(() => { + abandoned = true + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(abandoned).toBe(false) + + openGate() + await abandoning + + // Nothing is left to put the directory back after this returns. + await rm(root, { recursive: true }) + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(existsSync(root)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts new file mode 100644 index 00000000000..de928eb32e7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts @@ -0,0 +1,25 @@ +// What a host that VANISHES without a clean quit has to release, for the tests that model one. +// +// Deliberately not the quit path: quit evicts provider children and releases leases, and that is +// the state a restart test is checking gets re-derived from disk. It stops short of ownership. +// +// What it cannot skip is work already in flight, which is what every copy of this helper used to +// get wrong. Two producers reach the session store after the last awaited call has returned — a +// lease-renewal tick, and the restart the delivery loop wakes for an accepted send — and the store +// re-creates its own directory before every commit. A commit landing after the test removed its +// temp directory therefore puts that directory back, and the removal fails with ENOTEMPTY. Quit +// waits for both, in its `stop-lease-renewal` and `drain-attaches` phases; so does this. + +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +export async function abandonStructuredAgentSessionHost( + host: StructuredAgentSessionHost +): Promise { + // First, so the drain below cannot race the loop into enqueueing another step. + host['conversationDelivery'].loop.dispose() + host['lifetime'].dispose() + await host['runtimeState'].stopLeaseRenewal() + await host['tasks'].drainAttaches() + await Promise.all([...host['sessions'].values()].map((session) => session.journal.close())) + host['sessions'].clear() +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index 69e6d029d60..959a02919a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -1,6 +1,12 @@ -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalSnapshot +} from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { createStructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' import { attachFingerprintFields } from './structured-agent-session-attach' import type { AgentSessionAttachParams } from './structured-agent-session-attach' @@ -61,3 +67,23 @@ export function hostTestAttachParams( } } } + +/** The row ids a chat draws from `snapshot`, top to bottom, while its composer still holds `sent` + * as dispatched, as it does until the journal accepts them. */ +export function hostTestDrawnRowIds( + snapshot: AgentJournalSnapshot, + sent: readonly { clientMessageId: string; text: string }[] +): string[] { + const outbox = sent.map((message) => ({ + ...createStructuredAgentSessionOutboxEntry({ + ...message, + sessionId: HOST_TEST_SESSION, + attachments: [], + queuedAt: HOST_TEST_NOW + }), + state: 'dispatching' as const + })) + return projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions) + ).map(({ id }) => id) +} 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 e0ac9c00d88..1e0d77b0d52 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 @@ -107,7 +107,7 @@ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revis }) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null if (!appended) { throw new Error('provider approval was not written to the journal') 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 44611300c7c..44699536a6f 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 @@ -1,3 +1,4 @@ +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -19,7 +20,7 @@ export type StructuredAgentSessionCaller = { callerKey: string } /** What the host believes about a session it just made addressable again. The workspace and agent * come from the record, so a caller publishes the host's view rather than a client's assertion. * `readable` is false when the journal could not be opened — the tab is still worth publishing, - * because attach recovers what read restore cannot. */ + * because the chat shows that failure and its Retry. */ export type StructuredAgentSessionReveal = { sessionId: string workspaceId: string @@ -39,6 +40,9 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider /** A publish-first acquire is `starting` until the adapter's `started` event; only then are its * reported options fact. */ phase: StructuredAgentSessionProviderChildPhase + /** The queued message whose delivery started this child, fixed when the start is made; absent + * for any other start. In memory only: it tells a restart offer its own start from another. */ + readonly startedFor?: string } /** What ending a child established about its provider root. A stop's comes only from @@ -61,7 +65,10 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi cause: StructuredAgentSessionChildEndCause /** Descriptive text only — the provider's diagnostic, or the host's cause. Decides nothing. */ reason: string | null + /** What the chat records about this end; absent reads as a provider exit with no detail. */ + failure?: SubmissionRejectionFact duringStartup: boolean + startedFor?: string /** Where the conversation's journal stood when the child ended, to order the end against a * message's acceptance. */ endedAt: AgentJournalCursor @@ -106,8 +113,10 @@ export type StructuredAgentSessionHostDeps = { provider: AgentSessionRecord['provider'] ) => Promise | undefined> | Record | undefined now?: () => number - /** How long a session outlives its last surface. Tests drive this; production takes the default. */ - releaseGraceMs?: number + /** The idle sweep's period and window. Tests drive these; production takes the defaults. */ + idleSweep?: { intervalMs?: number; idleMs?: number } + /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ + hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ 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 3d0f5c9d427..58a84cecd5f 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 @@ -139,11 +139,13 @@ describe('attach', () => { }) const params = attachParams() + // Orca's own store fault: the child is gone, but nothing blames the provider. const refused = { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: 'agent_session_provider_handle_stale_fence', + details: { ownerVerdict: 'exited' }, + message: "Codex couldn't restart. Send your message to try again.", ownerVerdict: 'exited' } } @@ -161,7 +163,10 @@ describe('attach', () => { await expect(host.attach(CALLER, attachParams())).resolves.toMatchObject({ ok: false, - refusal: { message: 'commit failed', ownerVerdict: 'exited' } + refusal: { + message: "Codex couldn't restart. Send your message to try again.", + ownerVerdict: 'exited' + } }) expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) @@ -209,7 +214,7 @@ describe('cancel', () => { turnId: 'turn-1' }) expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items[0]?.body).toMatchObject({ kind: 'status', text: 'Cancellation requested.' @@ -333,7 +338,7 @@ describe('cancel', () => { refusal: { code: 'agent_session_operation_unknown' } }) expect(cancelTurn).toHaveBeenCalledTimes(1) - expect(host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ + expect(await host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ ok: true, page: { items: [ @@ -396,7 +401,7 @@ describe('respondToPrompt', () => { ...fields }) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const answered = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null expect(answered).toMatchObject({ revision: 2, @@ -491,7 +496,7 @@ describe('respondToPrompt', () => { ...fields }) expect(result.ok).toBe(true) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const statusId = agentJournalItemKey({ provider: 'orca', clientMessageId: `${prompt.itemId}#delivery` @@ -521,7 +526,7 @@ describe('setOption', () => { }) expect(setOption).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5', effort: 'high' }) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(0) }) @@ -605,13 +610,13 @@ describe('restart', () => { expect(host.listSessionTabs()).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items).not.toHaveLength(0) expect(acquire).not.toHaveBeenCalled() expect(listRecords).toHaveBeenCalledTimes(restoreReads) }) - it('clears a stale conflicted recovery at restart, and reacquires the native owner when a surface holds it', async () => { + it('clears a stale conflicted recovery at restart, and reacquires the native owner on the next start', async () => { await attach() await store.transitionHandoff(SESSION, (record) => ({ ...record, @@ -626,9 +631,10 @@ describe('restart', () => { acquire.mockClear() await host.restoreReadableSessions() - // The recovery stage clears on evidence at startup; the child comes back only once a surface - // holds the session (see structured-agent-session-surface-lifetime.test.ts). - await host.hold(SESSION, 'surface-1') + // The recovery stage clears on evidence at startup; the child comes back only once work + // starts it — here the explicit attach a send's delivery would make. + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(await host.attach(CALLER, ensureParams(fence))).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -660,12 +666,15 @@ describe('restart', () => { return settled(input) }) - const hold = host.hold(SESSION, 'surface-1') + const start = host.attach( + CALLER, + ensureParams(store.getRecord(SESSION)?.lease.runtimeFence ?? 0) + ) await started.promise const claimMidStart = store.getRecord(SESSION)?.lease.claimStatus const status = host.handoffStatus(SESSION) release.resolve() - await hold + await start // Mid-start the lease is only reserved; ownership does not wait for the agent. expect(claimMidStart).toBe('reserved') @@ -718,7 +727,7 @@ describe('subscribe', () => { it('opens with a snapshot and then streams cursor-qualified batches', async () => { await attach() const events: AgentSessionSubscribeEvent[] = [] - const dispose = host.subscribe({ + const dispose = await host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(event) @@ -748,7 +757,7 @@ describe('subscribe', () => { } const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-2', sessionId: SESSION, emit: (event) => events.push(event), @@ -769,14 +778,14 @@ describe('subscribe', () => { it('drops a failed transport without aborting the mutation or other subscribers', async () => { await attach() const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'dead-sub', sessionId: SESSION, emit: () => { throw new Error('socket closed') } }) - host.subscribe({ + await host.subscribe({ id: 'live-sub', sessionId: SESSION, emit: (event) => events.push(event) @@ -806,7 +815,7 @@ describe('subscribe', () => { it('resets a subscriber whose epoch is gone', async () => { await attach() const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-3', sessionId: SESSION, emit: (event) => events.push(event), @@ -818,7 +827,7 @@ describe('subscribe', () => { it('publishes the replacement fence when the owner generation changes', async () => { const record = await attach() const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-4', sessionId: SESSION, emit: (event) => events.push(event) 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 6462fbe5ad7..eba44143074 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 @@ -2,44 +2,46 @@ import type { AgentSessionRewindParams } from '../../../shared/agent-session-rew import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' // Structured agent-session host: where the lease, journal, and provider adapter meet. -// Mutations share one durable admission path and serialize per session. +// Mutations share one durable admission path and serialize per session. A conversation is reached +// only through `conversation`, which opens it at rest; an agent is started only by work that needs +// it, and the idle sweep is the one thing that puts it to rest. import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' -import { createStructuredAgentSessionHostRestore } from './structured-agent-session-reveal' +import { + createStructuredAgentSessionHostRestore, + revealStructuredAgentSession +} from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { - createStructuredAgentSessionHolds, - evictHeldStructuredAgentSession, - stopStructuredAgentSessionAgentUnderSerialize, - type StructuredAgentSessionLifetimeContext -} from './structured-agent-session-host-lifetime' -import type { - StructuredAgentSessionHolds, - StructuredAgentSessionHoldOptions -} from './structured-agent-session-holds' + ensureStructuredAgentSessionAgent, + ensureStructuredAgentSessionAgentForOperation +} from './structured-agent-session-agent-start' +import { + createStructuredAgentSessionConversationLifetime, + type StructuredAgentSessionConversationLifetime +} from './structured-agent-session-conversation-lifetime' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import * as sessionTabs from './structured-agent-session-host-tabs' import { structuredAgentSessionMutationDelegates, settleStructuredAgentSessionLateDispatch, - type StructuredAgentSessionMutationContext, - releaseStructuredAgentSessionUnansweredDispatches + type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' import { flushStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession, StructuredAgentSessionReveal } from './structured-agent-session-host-types' import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery' @@ -62,13 +64,16 @@ export class StructuredAgentSessionHost { ) private readonly sessions = new StructuredAgentSessionConversations({ deliver: (sessionId, journal) => this.subscribers.publish(sessionId, journal), - onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }) + onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }), + now: () => this.now() }) + // 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.holds.renew(sessionId) + (sessionId) => this.sessions.touch(sessionId), + (sessionId) => this.restartResume.onAgentStarted(sessionId) ) private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() @@ -77,7 +82,7 @@ export class StructuredAgentSessionHost { sessionId: string ) => Promise private readonly restore: ReturnType - private readonly holds: StructuredAgentSessionHolds + private readonly lifetime: StructuredAgentSessionConversationLifetime private readonly conversationDelivery: ReturnType< typeof createStructuredAgentSessionConversationDelivery > @@ -91,7 +96,7 @@ export class StructuredAgentSessionHost { deps, this.sessions, this.subscribers, - (sessionId) => this.requireSession(sessionId), + (sessionId) => this.lifetime.conversation(sessionId), this.clientDelivery.publishStatus ) this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (sessionId, error) => @@ -109,7 +114,8 @@ export class StructuredAgentSessionHost { serialize: (sessionId, task) => this.serialize(sessionId, task), // Quit drains a delivery start before it evicts, so the child it produces is stopped. trackStart: (start) => this.tasks.trackAttach(start), - ensureProviderChild: (sessionId) => this.holds.ensureProviderChild(sessionId), + ensureProviderChild: (sessionId, startedFor) => + ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), reset: (sessionId, journal, reset) => this.subscribers.reset( sessionId, @@ -119,11 +125,6 @@ export class StructuredAgentSessionHost { ), publishRestored: this.clientDelivery.publishRestored }) - this.holds = createStructuredAgentSessionHolds( - () => this.attachContext(), - (sessionId) => this.close(sessionId), - (sessionId) => this.conversationDelivery.loop.isRunning(sessionId) - ) this.restore = createStructuredAgentSessionHostRestore(deps, { reconcile: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), @@ -145,12 +146,8 @@ export class StructuredAgentSessionHost { structuredAgentSessionConversationFence(deps.store, sessionId) ), publishStatus: this.clientDelivery.publishStatusAndSettlement, - hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId), - restartReleaseGrace: (sessionId) => this.holds.renew(sessionId), - // Tracked: a quit drains a queued restart before it evicts, so no child outlives it. serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)), now: () => this.now(), - ensureProviderChild: (id, options) => this.holds.ensureProviderChild(id, options), onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) this.restartResume = createStructuredAgentSessionRestartResume( @@ -158,25 +155,21 @@ export class StructuredAgentSessionHost { this.sessions, structuredAgentSessionRestartResumeSurfaces(this, this.now) ) + this.lifetime = createStructuredAgentSessionConversationLifetime({ + context: () => this.lifetimeContext(), + sessions: this.sessions, + serialize: (sessionId, task) => this.serialize(sessionId, task), + open: (sessionId) => this.conversationDelivery.open(sessionId), + deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), + closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options) + }) this.runtimeState.startLeaseRenewal() + this.lifetime.idleSweep.start() } private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) - isHeld = (sessionId: string): boolean => this.holds.isHeld(sessionId) - - /** A surface bound to this session and wants it live. The FIRST hold on a session with no - * provider child is what resumes one, unless its last start failed; a retained hold (a - * subscription) only keeps it. */ - hold = ( - sessionId: string, - holderId: string, - options?: StructuredAgentSessionHoldOptions - ): Promise => this.holds.hold(sessionId, holderId, options) - - /** That surface is gone. The child outlives it by the idle window, and by any running turn. */ - release = (sessionId: string, holderId: string): void => this.holds.release(sessionId, holderId) handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) @@ -187,7 +180,6 @@ export class StructuredAgentSessionHost { runtimeState: this.runtimeState, sessions: this.sessions, now: () => this.now(), - forgetStatus: this.clientDelivery.forgetStatus, publishStatus: this.clientDelivery.publishStatus } } @@ -204,18 +196,8 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open } } - /** Releases a session's resources without ending the conversation: the record and journal stay - * on disk, so the same session can be attached again. */ - close(sessionId: string): Promise { - return this.serialize(sessionId, () => this.closeUnderSerialize(sessionId)) - } - - private async closeUnderSerialize(sessionId: string): Promise { - await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) - this.clientDelivery.closeSession(sessionId) - // The holders now look at a session that is gone; a failed eviction throws above, keeping them. - this.holds.forget(sessionId) - } + /** Releases a session's resources without ending the conversation; see the lifetime's close. */ + close = (sessionId: string): Promise => this.lifetime.close(sessionId) supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) @@ -224,8 +206,17 @@ export class StructuredAgentSessionHost { getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) - setSessionTabVisibility = (sessionId: string, visible: boolean, tabId?: string): Promise => - sessionTabs.setStructuredAgentSessionTabVisibility(this, sessionId, visible, tabId) + setSessionTabVisibility = async ( + sessionId: string, + visible: boolean, + tabId?: string + ): Promise => { + await sessionTabs.setStructuredAgentSessionTabVisibility(this, sessionId, visible, tabId) + // The tab edge of the row's lifetime; the handle close is the other. + if (!visible && !this.sessions.get(sessionId)?.child) { + this.clientDelivery.forgetStatus(sessionId) + } + } reconcileRestartLeases = async (): Promise => { const refusal = await this.reconcileLeases('startup') @@ -239,7 +230,7 @@ export class StructuredAgentSessionHost { /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => - this.restore.revealSession(sessionId) + revealStructuredAgentSession(this.deps, sessionId, (id) => this.lifetime.conversation(id)) private serialize = this.tasks.serialize.bind(this.tasks) @@ -259,7 +250,7 @@ export class StructuredAgentSessionHost { this.conversationDelivery.loop.dispose() await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), - holds: this.holds, + idleSweep: this.lifetime, tasks: this.tasks, restartResume: this.restartResume, serialize: this.serialize, @@ -273,14 +264,13 @@ export class StructuredAgentSessionHost { sessions: this.sessions, publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), flushStreamedEvents: this.flushStreamedEvents, - requireSession: (sessionId) => this.requireSession(sessionId), + conversation: this.lifetime.conversation, serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, + ensureAgent: (sessionId) => + ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopAgent: (sessionId) => - stopStructuredAgentSessionAgentUnderSerialize(this.lifetimeContext(), sessionId, { - cause: 'user-stop' - }), + stopAgent: this.lifetime.stopAgent, now: () => this.now() } } @@ -316,10 +306,10 @@ export class StructuredAgentSessionHost { /** 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. */ - journalSnapshot = (sessionId: string): AgentJournalSnapshot => - this.requireSession(sessionId).journal.snapshot() + journalSnapshot = async (sessionId: string): Promise => + (await this.lifetime.conversation(sessionId)).journal.snapshot() - subscribe = (input: AgentSessionSubscribeInput): (() => void) => + subscribe = (input: AgentSessionSubscribeInput): Promise<() => void> => this.backgroundTasks.subscribe(input) settleLateDispatch = (input: Parameters[1]) => @@ -340,11 +330,13 @@ export class StructuredAgentSessionHost { /** Turns that settle from now on. Live-only: nothing missed is replayed. */ subscribeTurnCompletions = this.clientDelivery.subscribeTurnCompletions - private requireSession(sessionId: string): StructuredAgentSessionHostSession { - const session = this.sessions.get(sessionId) - if (!session) { - throw new Error(AGENT_SESSION_NOT_ATTACHED.code) - } - return session - } + /** Test rigs only: the collaborators the host builds itself, typed, for tests that drive them. */ + collaboratorsForTests = () => ({ + sessions: this.sessions, + subscribers: this.subscribers, + runtimeState: this.runtimeState, + conversationDelivery: this.conversationDelivery, + lifetime: this.lifetime, + serialize: this.serialize + }) } 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 new file mode 100644 index 00000000000..ebc2009fa7d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -0,0 +1,314 @@ +// 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. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { + collectSubscriber, + createRestTestRig, + foundRestTestChat, + readerSaw, + IDLE_MS, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + REST_TEST_THREAD as THREAD, + restTestSend, + sweepTicks, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' + +let rig: RestTestRig + +beforeEach(async () => { + rig = await createRestTestRig() +}) + +afterEach(async () => { + await rig.dispose() +}) + +function providerEvents() { + const events = rig.adapter.acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no provider child was started') + } + return events +} + +function lastStatus(): AgentSessionStatusSummary | undefined { + return rig.statusEvents + .flatMap((event) => (event.type === 'status' ? [event.session] : [])) + .findLast((summary) => summary.sessionId === SESSION) +} + +function fence(): number { + return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 +} + +describe('the idle sweep', () => { + it('stops an idle agent and keeps the conversation, its status row and its reader (P2-07)', async () => { + await foundRestTestChat(rig) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + rig.clock.now += IDLE_MS + 1 + + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released')) + await vi.waitFor(() => expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledOnce()) + expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledWith(SESSION) + // Listed, idle, and no longer running on this host — never dropped from any list. + expect(lastStatus()).toMatchObject({ status: 'idle' }) + expect(lastStatus()?.hostExecutionOwned).toBeUndefined() + expect(rig.sink.forget).not.toHaveBeenCalled() + expect(reader.events.some((event) => event.type === 'end')).toBe(false) + + // Everything a chat at rest answers still answers, and the next send starts a new agent. + await expect(rig.host.readOptions(SESSION)).resolves.toMatchObject({ current: {} }) + const sent = await rig.host.send(CALLER, restTestSend('carry on', fence())) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('carry on')) + }) + + it('never stops an agent a message is queued for, and hands the message over (P2-08)', async () => { + await foundRestTestChat(rig) + rig.adapter.closeSession.mockClear() + // The loop takes the message and waits on the child's start, outside the lock. + const started = Promise.withResolvers() + const awaitStarted = vi.fn(() => started.promise) + Object.assign(rig.host.deps.adapter, { awaitStarted }) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + const sent = await rig.host.send(CALLER, restTestSend('queued one', fence())) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(awaitStarted).toHaveBeenCalled()) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + started.resolve() + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + const settled = (await rig.host.journalSnapshot(SESSION)).submissions.at(-1) + expect(settled?.dispatchState).toBe('accepted') + const seen = readerSaw(reader.events).submissions + expect(seen.some((row) => row.dispatchState === 'accepted')).toBe(true) + expect( + seen.some((row) => row.dispatchState === 'rejected' || row.dispatchState === 'unknown') + ).toBe(false) + }) + + it('never stops an agent whose background work still runs (P2-09)', async () => { + await foundRestTestChat(rig) + // A Codex subagent thread is one of these tasks; the tracker projects both kinds alike. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background roster fixture; the sweep reads only whether live tasks exist. + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [ + { + taskId: 'subagent-1', + kind: 'subagent', + status: 'running', + title: 'reviewer', + startedAt: rig.clock.now + } + ] + } as never) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.adapter.backgroundTaskState.mockReturnValue(undefined) + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + // A finished child reads done before the lead's wake-up turn writes its first row; stopping the + // agent in that gap would lose the wake-up. Owed work is activity, as main's release clock had it. + it('gives an agent a full idle window after its background work ends', async () => { + await foundRestTestChat(rig) + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [{ id: 'subagent-1', kind: 'agent', state: 'working' }] + }) + rig.clock.now += IDLE_MS + 1 + await sweepTicks() + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [{ id: 'subagent-1', kind: 'agent', state: 'done' }] + }) + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + // Owed work is read every tick, not once a window: work that ends just before a window would + // have closed still leaves the agent a full window after it. + it('gives a full window after background work that ends late in a window', async () => { + await foundRestTestChat(rig) + const subagent = (state: 'working' | 'done') => ({ + state: 'monitoring' as const, + tasks: [{ id: 'subagent-1', kind: 'agent' as const, state }] + }) + rig.adapter.backgroundTaskState.mockReturnValue(subagent('working')) + rig.clock.now += IDLE_MS + 1 + await sweepTicks() + rig.clock.now += IDLE_MS - 60_000 + await sweepTicks() + rig.adapter.backgroundTaskState.mockReturnValue(subagent('done')) + rig.clock.now += 60_000 + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('stops an agent whose roster holds only children that went idle or finished', async () => { + await foundRestTestChat(rig) + rig.adapter.backgroundTaskState.mockReturnValue({ + state: 'monitoring', + tasks: [ + { id: 'subagent-1', kind: 'agent', state: 'idle' }, + { id: 'command-1', kind: 'command', state: 'done' } + ] + }) + rig.clock.now += IDLE_MS + 1 + + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => { + await foundRestTestChat(rig) + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 50 }, + { kind: 'turn', turnId: 'working', state: 'running' } + ) + await rig.host.flushStreamedEvents(SESSION) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + }) + + it('stops an agent whose chat is still open on screen (P2-12)', async () => { + await foundRestTestChat(rig) + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'on-screen', sessionId: SESSION, emit: reader.emit }) + rig.clock.now += IDLE_MS + 1 + + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + expect(reader.events.some((event) => event.type === 'end')).toBe(false) + }) + + it('counts the idle window from the last activity (P2-13)', async () => { + await foundRestTestChat(rig) + rig.clock.now += IDLE_MS - 60_000 + // 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' } + ) + await rig.host.flushStreamedEvents(SESSION) + rig.clock.now += 60_001 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => { + await rig.dispose() + let open = true + const hasOpenDispatch = vi.fn(() => open) + rig = await createRestTestRig({ hasOpenDispatch }) + await foundRestTestChat(rig) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks(12) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION })) + open = false + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + + it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { + await foundRestTestChat(rig) + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'subagent-turn', ordinal: 70 }, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + ) + await rig.host.flushStreamedEvents(SESSION) + rig.clock.now += IDLE_MS + 1 + + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + }) +}) + +describe('the idle sweep with no child running (P2-22 ii)', () => { + it('closes a handle whose only leftover is a prompt nobody can answer', async () => { + const journal = { + submissions: () => [], + pendingSubmissions: () => [], + snapshot: () => ({ + items: [ + { + itemId: 'prompt', + revision: 1, + sequence: 1, + observedAt: 0, + body: { + kind: 'approval', + title: 'Run?', + detail: null, + options: [], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + } + ] + }) + } + const sessions = Object.assign( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a session fixture carrying only the journal and child facts the sweep reads. + new Map([[SESSION, { journal, child: null } as never]]), + { lastActivityAt: () => 0, touch: () => undefined } + ) + const stopAgent = vi.fn(async () => undefined) + const closeConversation = vi.fn(async () => true) + const sweep = new StructuredAgentSessionIdleSweep({ + sessions, + serialize: (_id, task) => task(), + now: () => IDLE_MS + 1, + isDisposed: () => false, + deliveryActive: () => false, + backgroundTaskState: () => undefined, + hasOpenDispatch: () => false, + stopAgent, + stopStartingAgent: stopAgent, + closeConversation, + onError: (_id, error) => { + throw error + } + }) + await sweep.tick() + expect(stopAgent).not.toHaveBeenCalled() + expect(closeConversation).toHaveBeenCalledWith(SESSION) + }) +}) 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 new file mode 100644 index 00000000000..2394b604171 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -0,0 +1,151 @@ +// The one thing that puts an idle agent to rest. +// +// 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 +// 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. + +import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' + +export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 +export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000 + +export type StructuredAgentSessionIdleSweepDeps = { + sessions: ReadonlyMap & { + lastActivityAt: (sessionId: string) => number | undefined + touch: (sessionId: string) => void + } + serialize: (sessionId: string, task: () => Promise) => Promise + now: () => number + isDisposed: () => boolean + deliveryActive: (sessionId: string) => boolean + backgroundTaskState: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined + /** An orchestration dispatch that still owns this session's worker; derived from its database. */ + hasOpenDispatch: (sessionId: string) => boolean + /** Each of these runs inside the session's serialize and never takes it again. */ + stopAgent: (sessionId: string) => Promise + stopStartingAgent: (sessionId: string) => Promise + closeConversation: (sessionId: string) => Promise + onError: (sessionId: string, error: unknown) => void + intervalMs?: number + idleMs?: number +} + +/** A prompt the user has not answered. A subagent can raise one the lead turn cannot see. */ +export function hasPendingStructuredAgentSessionPrompt( + items: readonly AgentJournalRenderItem[] +): boolean { + return items.some( + (item) => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) +} + +export class StructuredAgentSessionIdleSweep { + private timer: ReturnType | null = null + private running = false + + constructor(private readonly deps: StructuredAgentSessionIdleSweepDeps) {} + + start(): void { + this.timer = setInterval( + () => void this.tick(), + this.deps.intervalMs ?? STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS + ) + // An idle sweep must never be the reason a process stays alive at quit. + this.timer.unref?.() + } + + dispose(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + /** One pass over every open conversation. Sessions run concurrently, so one waiting behind a + * locked start does not hold up the rest; a pass still running skips the next. */ + async tick(): Promise { + if (this.running || this.deps.isDisposed()) { + return + } + this.running = true + try { + await Promise.allSettled( + [...this.deps.sessions.keys()].map((sessionId) => + this.deps + .serialize(sessionId, () => this.tickUnderSerialize(sessionId)) + .catch((error: unknown) => this.deps.onError(sessionId, error)) + ) + ) + } finally { + this.running = false + } + } + + /** Re-derives everything inside the session's lock, so an accept already queued ahead is seen. */ + private async tickUnderSerialize(sessionId: string): Promise { + const session = this.deps.sessions.get(sessionId) + if (!session || this.deps.isDisposed()) { + return + } + // A stop that failed after the child was proven gone: finish it now, before the idle test, so + // the rows its settlement wrote cannot push the retry out. A message accepted since goes first. + if ( + session.owesProviderChildWindDown !== undefined && + !session.child && + !this.queuedOrDelivering(sessionId, session) + ) { + await this.deps.stopAgent(sessionId) + return + } + // Owed work is activity, read every tick, so the agent gets a full window once it ends: a child + // can read done before the lead's wake-up turn writes anything. + if (session.child && session.child.phase !== 'starting' && this.owesWork(sessionId, session)) { + this.deps.sessions.touch(sessionId) + return + } + const lastActivityAt = this.deps.sessions.lastActivityAt(sessionId) ?? this.deps.now() + if (this.deps.now() - lastActivityAt < (this.deps.idleMs ?? STRUCTURED_AGENT_SESSION_IDLE_MS)) { + return + } + if (session.child) { + // A start that has been quiet this long is not coming: the host stops it, with its reason. + if (session.child.phase === 'starting') { + await this.deps.stopStartingAgent(sessionId) + return + } + await this.deps.stopAgent(sessionId) + } + await this.deps.closeConversation(sessionId) + } + + private queuedOrDelivering(sessionId: string, session: StructuredAgentSessionHostSession) { + return ( + this.deps.deliveryActive(sessionId) || + session.journal.submissions().some(isQueuedAgentJournalSubmission) + ) + } + + /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the + * handle, and a leftover prompt or turn row is only history. */ + private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { + const items = session.journal.snapshot().items + return ( + activeStructuredAgentSessionTurnId(items) !== null || + this.queuedOrDelivering(sessionId, session) || + agentChildWorkLiveness(this.deps.backgroundTaskState(sessionId)?.tasks) !== null || + this.deps.hasOpenDispatch(sessionId) || + hasPendingStructuredAgentSessionPrompt(items) + ) + } +} 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 index 942c9ef6152..8dabd942705 100644 --- 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 @@ -15,7 +15,7 @@ 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 { forgetStructuredAgentSession } from './structured-agent-session-host-lifetime' +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' @@ -73,10 +73,6 @@ function hostSession(journal: AgentSessionJournal): StructuredAgentSessionHostSe } } -function forgetContext(sessions: Map) { - return { deps: { store: { getRecord: () => null } }, sessions, forgetStatus: () => undefined } -} - beforeEach(async () => { legacyImport.throws = false root = await mkdtemp(join(tmpdir(), 'orca-wire-handles-')) @@ -119,35 +115,42 @@ describe('site 6: recovery rehydration', () => { }) describe('sites 9 and 10: closing a conversation handle', () => { - it('awaits the journal close before dropping the map entry', async () => { + 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 () => { - order.push('close-started') + // 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(sessions.has(SESSION) ? 'closed' : 'dropped-before-close') + order.push('closed') } - await forgetStructuredAgentSession(forgetContext(sessions), SESSION) + await expect( + closeStructuredAgentSessionConversationUnderSerialize( + { sessions, closeStatus: () => order.push('status') }, + SESSION + ) + ).resolves.toBe(true) - expect(order).toEqual(['close-started', 'closed']) + expect(order).toEqual(['status', 'close-after-delete', 'closed']) expect(sessions.size).toBe(0) await expectNothingHoldsTheDirectory(journalDir) }) - it('keeps the session indexed when the close rejects', async () => { + 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(forgetStructuredAgentSession(forgetContext(sessions), SESSION)).rejects.toThrow( - 'close rejected' - ) - // Still indexed, so the next close is a real retry. - expect(sessions.has(SESSION)).toBe(true) + await expect( + closeStructuredAgentSessionConversationUnderSerialize( + { sessions, closeStatus: () => undefined }, + SESSION + ) + ).rejects.toThrow('close rejected') journal.close = close }) }) 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 6bda0b66189..2194b992fb0 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 @@ -24,6 +24,8 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CALLER = { callerKey: 'client-1' } @@ -60,8 +62,8 @@ function sendParams(text: string): { } } -function submissions(): unknown { - const state = host.history({ sessionId: SESSION, direction: 'tail' }) +async function submissions(): Promise { + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) return state.ok ? state.page.submissions : null } @@ -73,7 +75,7 @@ function journal(): AgentSessionJournal { /** A send is accepted first; this waits for the delivery loop to hand it to the provider. */ async function handedOver(clientMessageId: string): Promise { - await vi.waitFor(() => { + await vi.waitFor(async () => { expect( journal() .submissions() @@ -138,14 +140,14 @@ describe('settling a send the provider proves it received after the ack window', }) ) const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'late-receipt', sessionId: SESSION, emit: (event) => events.push(event) }) const params = sendParams('echo before send completes') const pending = host.send(CALLER, params) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await vi.waitFor(async () => expect(dispatch).toHaveBeenCalledTimes(1)) try { await host.settleLateDispatch({ sessionId: SESSION, @@ -166,8 +168,8 @@ describe('settling a send the provider proves it received after the ack window', } await expect(pending).resolves.toMatchObject({ ok: true }) // The late `unknown` from the handover does not reopen the proven acceptance. - await vi.waitFor(() => - expect(submissions()).toMatchObject([ + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } ]) ) @@ -183,7 +185,9 @@ describe('settling a send the provider proves it received after the ack window', const params = sendParams('received just before shutdown') await host.send(CALLER, params) await handedOver(params.envelope.clientOperationId) - await vi.waitFor(() => expect(submissions()).toMatchObject([{ dispatchState: 'unknown' }])) + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([{ dispatchState: 'unknown' }]) + ) let settlement: Promise | undefined closeSession.mockImplementationOnce(async () => { settlement = host.settleLateDispatch({ @@ -198,7 +202,7 @@ describe('settling a send the provider proves it received after the ack window', await host.close(SESSION) await expect(settlement).resolves.toBeUndefined() await host.revealSession(SESSION) - expect(submissions()).toMatchObject([{ dispatchState: 'accepted' }]) + expect(await submissions()).toMatchObject([{ dispatchState: 'accepted' }]) expect(dispatch).toHaveBeenCalledTimes(1) }) @@ -207,7 +211,9 @@ describe('settling a send the provider proves it received after the ack window', const params = sendParams('sent while a turn was running') await host.send(CALLER, params) await handedOver(params.envelope.clientOperationId) - await vi.waitFor(() => expect(submissions()).toMatchObject([{ dispatchState: 'unknown' }])) + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([{ dispatchState: 'unknown' }]) + ) await host.settleLateDispatch({ sessionId: SESSION, @@ -215,7 +221,7 @@ describe('settling a send the provider proves it received after the ack window', providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'late-uuid' } }) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } ]) // The point of the fix: the client stops rendering Retry, and Retry is what @@ -233,14 +239,15 @@ describe('settling a send the provider proves it received after the ack window', sessionId: SESSION, clientMessageId: params.envelope.clientOperationId, state: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) }) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } } ]) }) @@ -267,7 +274,7 @@ describe('settling a send the provider proves it received after the ack window', { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } ) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted', @@ -279,7 +286,9 @@ describe('settling a send the provider proves it received after the ack window', it('leaves an already accepted send alone', async () => { const params = sendParams('ordinary send') await host.send(CALLER, params) - await vi.waitFor(() => expect(submissions()).toMatchObject([{ dispatchState: 'accepted' }])) + await vi.waitFor(async () => + expect(await submissions()).toMatchObject([{ dispatchState: 'accepted' }]) + ) await host.settleLateDispatch({ sessionId: SESSION, @@ -287,7 +296,7 @@ describe('settling a send the provider proves it received after the ack window', providerIdentity: { provider: 'claude', sessionId: THREAD, uuid: 'a-different-uuid' } }) - expect(submissions()).toMatchObject([ + expect(await submissions()).toMatchObject([ { clientMessageId: params.envelope.clientOperationId, dispatchState: 'accepted' } ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts index e7bd9a9ac85..cd26b96e5cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts @@ -62,12 +62,11 @@ async function launchAndDeliver(): Promise<{ } // Accepted first; the delivery loop hands it over, and that outcome is what reached the agent. let dispatchState = sent.value.submission.dispatchState - await vi.waitFor(() => { + await vi.waitFor(async () => { dispatchState = - host - .journalSnapshot(created.value.sessionId) - .submissions.find((entry) => entry.clientMessageId === sent.value.clientMessageId) - ?.dispatchState ?? 'missing' + (await host.journalSnapshot(created.value.sessionId)).submissions.find( + (entry) => entry.clientMessageId === sent.value.clientMessageId + )?.dispatchState ?? 'missing' expect(dispatchState).not.toBe('pending') }) return { messageId, dispatchState } 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 f969e3c0bbf..9545e42b051 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 @@ -190,11 +190,36 @@ describe('structured agent-session lease renewal', () => { { timeout: 5000 } ) } finally { - renewer.stop() + await renewer.stop() vi.useRealTimers() } }) + it('stops only once a renewal already in flight has finished writing', async () => { + const store = await liveStore() + let releaseProbe = (): void => {} + const probing = new Promise((resolve) => { + releaseProbe = resolve + }) + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => { + await probing + return { outcome: 'identity-matched', matchedOn: ['process-start-time'] } + }, + now: () => NOW + 10_000 + }) + + void renewer.renewNow() + // Nothing has been written yet: the tick is parked in its probe. + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) + const stopped = renewer.stop() + releaseProbe() + await stopped + + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW + 10_000) + }) + it('renews every live owner only after re-proving its child identity', async () => { const store = await liveStore() const probe = vi.fn(async () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index 96eb344fee9..a61e11b6f4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -10,6 +10,9 @@ const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3) export class StructuredAgentSessionLeaseRenewer { private timer: ReturnType | null = null private running = false + /** The tick in flight. Never rejects: the timer path reports renewal failures through `onError`, + * and stopping must not turn one into a teardown failure as well. */ + private inFlight: Promise = Promise.resolve() constructor( private readonly input: { @@ -32,70 +35,81 @@ export class StructuredAgentSessionLeaseRenewer { this.timer.unref?.() } - stop(): void { + /** Clearing the interval only stops the NEXT tick. A tick already past its guard still has a + * store transaction to commit, and that transaction re-creates the store directory, so a stop + * that returned before it landed would let the write outlive whatever tore the host down. */ + stop(): Promise { if (this.timer) { clearInterval(this.timer) this.timer = null } + return this.inFlight } - async renewNow(): Promise { + renewNow(): Promise { if (this.running) { - return + return this.inFlight } this.running = true - try { - const records = this.input.store.listRecords().filter( - (record) => - !record.lease.unreconciled && - record.lease.claimStatus === 'live' && - record.lease.ownerProcess !== null && - // A record parked in recovery has no transport the host can vouch for; renewing it - // keeps an orphan pid's lease reading as a healthy owner. - record.lease.handoffStage !== 'recovering' - ) - const probes = await this.probe(records) - const renewals: { - sessionId: string - fence: number - childProbe: AgentSessionOwnerProbe - now: number - }[] = [] - const now = this.input.now() - for (const record of records) { - const probe = probes.get(record.sessionId) - if (!probe) { - continue - } - renewals.push({ - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - childProbe: probe, - now - }) - } - // The store persists the whole record file per transaction, so keep the healthy path to - // one commit. If one renewal is superseded, retrying individually preserves isolation. - let results: PromiseSettledResult[] - try { - const renewed = await this.input.store.renewLeases(renewals) - results = renewed.map((record) => ({ status: 'fulfilled', value: record }) as const) - } catch { - results = await Promise.allSettled( - renewals.map((renewal) => this.input.store.renewLease(renewal)) - ) - } - results.forEach((result, index) => { - if (result.status === 'rejected') { - const renewal = renewals[index] - if (renewal) { - this.input.onError?.({ sessionId: renewal.sessionId, error: result.reason }) - } - } - }) - } finally { + const attempt = this.renewOnce().finally(() => { this.running = false + }) + this.inFlight = attempt.then( + () => undefined, + () => undefined + ) + return attempt + } + + private async renewOnce(): Promise { + const records = this.input.store.listRecords().filter( + (record) => + !record.lease.unreconciled && + record.lease.claimStatus === 'live' && + record.lease.ownerProcess !== null && + // A record parked in recovery has no transport the host can vouch for; renewing it + // keeps an orphan pid's lease reading as a healthy owner. + record.lease.handoffStage !== 'recovering' + ) + const probes = await this.probe(records) + const renewals: { + sessionId: string + fence: number + childProbe: AgentSessionOwnerProbe + now: number + }[] = [] + const now = this.input.now() + for (const record of records) { + const probe = probes.get(record.sessionId) + if (!probe) { + continue + } + renewals.push({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + childProbe: probe, + now + }) } + // The store persists the whole record file per transaction, so keep the healthy path to + // one commit. If one renewal is superseded, retrying individually preserves isolation. + let results: PromiseSettledResult[] + try { + const renewed = await this.input.store.renewLeases(renewals) + results = renewed.map((record) => ({ status: 'fulfilled', value: record }) as const) + } catch { + results = await Promise.allSettled( + renewals.map((renewal) => this.input.store.renewLease(renewal)) + ) + } + results.forEach((result, index) => { + if (result.status === 'rejected') { + const renewal = renewals[index] + if (renewal) { + this.input.onError?.({ sessionId: renewal.sessionId, error: result.reason }) + } + } + }) } private async probe( 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 f61dd491054..213126fb5eb 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 @@ -26,7 +26,7 @@ import { const CALLER = { callerKey: 'client-1' } /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void): Promise { +function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } @@ -83,15 +83,14 @@ async function delivered(text: string) { const sent = await send(text) expect(sent).toMatchObject({ ok: true }) const clientMessageId = sent.ok ? sent.value.clientMessageId : '' - const submission = () => - host - .journalSnapshot(SESSION) - .submissions.find((candidate) => candidate.clientMessageId === clientMessageId) - await eventually(() => - expect( - submission()?.dispatchState !== 'pending' || submission()?.handedOverAt !== undefined - ).toBe(true) - ) + const submission = async () => + (await host.journalSnapshot(SESSION)).submissions.find( + (candidate) => candidate.clientMessageId === clientMessageId + ) + await eventually(async () => { + const current = await submission() + expect(current?.dispatchState !== 'pending' || current?.handedOverAt !== undefined).toBe(true) + }) return submission() } @@ -245,19 +244,20 @@ describe('a record an older build left mid terminal handoff', () => { handoffStage: 'recovering' }) // Sending and opening the chat both say what frees it: quitting that terminal agent. A send is - // accepted, then rejected by the start that cannot take the lease, and the chat's row says why. + // accepted, then rejected by the start that cannot take the lease, and the chat's row says why, + // worded from the refusal's details; only the live refusal names the process. const quitTerminal = 'This chat is still open in a terminal agent (process 4242). Quit that agent to continue the chat here.' expect(await delivered('while the terminal still runs')).toMatchObject({ dispatchState: 'rejected' }) expect( - host - .journalSnapshot(SESSION) - .items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) - ).toEqual([expect.stringContaining(quitTerminal)]) + (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) + ).toEqual([ + "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ]) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: false, @@ -267,9 +267,8 @@ describe('a record an older build left mid terminal handoff', () => { expect(stopOwnerProcess).not.toHaveBeenCalled() expect(acquire).not.toHaveBeenCalled() - // The user closes the terminal; the next open proves it gone and the chat takes over. + // The user closes the terminal; the next send's start proves it gone and the chat takes over. probe.mockResolvedValue({ outcome: 'pid-absent' }) - await host.hold(SESSION, 'surface-1') expect(stopOwnerProcess).not.toHaveBeenCalled() expect(await delivered('after the terminal closed')).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 24f341d28da..6a623428e4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -15,10 +15,11 @@ import { } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionOperationDecision } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionWireRefusal +import { + refuse, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -31,10 +32,11 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns' // The code is shared with the client so a read that refuses this way can be told apart from a // transcript that failed to load; the two must never drift apart. -export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = { - code: AGENT_SESSION_UNATTACHED_REFUSAL_CODE, - message: 'This host holds no attached session by that id.' -} +export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = refuse( + AGENT_SESSION_UNATTACHED_REFUSAL_CODE, + { reason: 'sessionNotAttached' }, + 'This host holds no attached session by that id.' +) export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { ok: false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index 5d5009aa918..e4675bfefb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -41,6 +41,8 @@ export async function runSettledAgentSessionMutation(input: { : { status: 'failed', code: outcome.refusal.code, + ...(outcome.refusal.details ? { details: outcome.refusal.details } : {}), + // The row's own field, which builds before details read; copied from the legacy mirror. ...(outcome.refusal.rewindReason ? { rewindReason: outcome.refusal.rewindReason } : {}) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts new file mode 100644 index 00000000000..7700a616b38 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -0,0 +1,61 @@ +// What an operation that needs the agent is told when it cannot have one: a typed refusal in +// words a person can read, never Orca's own error text. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' +import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' + +const SESSION = 'session-1' + +describe('an operation whose agent start throws', () => { + it('is refused as a failed restart, with the error only in the log', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const cause = new Error('EACCES: permission denied, open /Users/me/.orca/leases.json') + const context = { + sessions: new Map(), + reconcileLeases: () => Promise.reject(cause) + } + + const refused = await ensureStructuredAgentSessionAgentForOperation( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the start throws at `reconcileLeases`, before any other member is read. + context as unknown as StructuredAgentSessionAttachContext, + SESSION + ) + + expect(refused).toEqual({ + ok: false, + refusal: { + code: 'agent_session_owner_restart_failed', + message: "The agent couldn't restart." + } + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('starting the agent'), cause) + warn.mockRestore() + }) +}) + +describe('an option picked while the chat is at rest', () => { + it('refuses a key the provider would not accept as a rejected option', async () => { + const persistOptions = vi.fn(async () => {}) + const refused = await recordStructuredAgentSessionOptionIntent( + { + getRecord: () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the intent reads only the record's provider and options. + ({ provider: 'codex', options: {} }) as unknown as AgentSessionRecord + }, + { sessionId: SESSION, persistOptions, publish: () => {} }, + { key: 'notAnOption', value: 'x' } + ) + + expect(refused).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'optionRejected' } + } + }) + expect(persistOptions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts index 607db1e6c4b..47f67910cf9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts @@ -1,6 +1,10 @@ /** Option validation failed before the provider session was mutated. */ export class AgentSessionOptionRejectedError extends Error { - constructor(cause: unknown) { + constructor( + cause: unknown, + /** `providerStarting`: nothing is wrong with the value; the session cannot take it yet. */ + readonly refusalReason: 'optionRejected' | 'providerStarting' = 'optionRejected' + ) { super(cause instanceof Error ? cause.message : String(cause), { cause }) this.name = 'AgentSessionOptionRejectedError' } 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 853fa5784a0..3eae8a7ae08 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 @@ -154,8 +154,8 @@ describe('structured session options and close', () => { }) // Accepted, then handed over by the delivery loop; the status is read once it answered. await vi.waitFor(() => expect(dispatchedModels).toEqual([DEFAULT_MODEL])) - await vi.waitFor(() => - expect(host.journalSnapshot(SESSION).submissions[0]?.dispatchState).toBe('accepted') + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') ) const events: AgentSessionStatusEvent[] = [] host.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts new file mode 100644 index 00000000000..3bad8d9789f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -0,0 +1,134 @@ +// A chat's options, live or at rest. +// +// At rest nothing here needs a child. The pick is the record's own `options` — what the next start replays — +// and the list is the host's model catalog, the same one the picker already reads before a chat +// exists. A pick made at rest is written to the record as intent, through the same transition a live +// pick takes, so the next start applies it. + +import { + refuse, + type AgentSessionOptionResult, + type AgentSessionOptionsResult +} from '../../../shared/agent-session-wire' +import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' +import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options' +import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' + +type RestingOptions = Pick + +async function readStructuredAgentSessionOptionsAtRest( + deps: Pick, + sessionId: string +): Promise { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + const catalog = (await deps.modelCatalog + ?.read({ agent: record.provider, sessionId }) + .catch(() => null)) ?? { origin: 'unknown' as const } + const models = catalog.origin === 'unknown' ? [] : catalog.models + const saved = record.options ?? {} + const fastMode = + saved.fastMode === undefined + ? null + : decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode) + // An unknown model is one the client already treats as unconfirmed. + const model = saved.model ?? models.find((entry) => entry.isDefault)?.id ?? '' + // As a live child answers: the pick, else what Claude runs for this model when none is sent. + // A live Codex child answers only the effort its thread reported, never the model's default. + const effort = + saved.effort ?? + (record.provider === 'claude' + ? models.find((entry) => entry.id === model)?.defaultEffort + : undefined) + return { + models, + ...(catalog.origin !== 'unknown' && catalog.fastModeSupport + ? { fastModeSupport: catalog.fastModeSupport } + : {}), + current: { + model, + ...(effort ? { effort } : {}), + ...(typeof fastMode === 'boolean' ? { fastMode } : {}) + } + } +} + +/** Records a pick for the next start. Only a key the provider would accept is kept. */ +export async function recordStructuredAgentSessionOptionIntent( + store: Pick, + ctx: Pick, + input: { key: string; value: string } +): Promise> { + const record = store.getRecord(ctx.sessionId) + const accepted = + record?.provider === 'codex' + ? isCodexTurnOptionKey(input.key) + : record?.provider === 'claude' && isClaudeStructuredOptionKey(input.key) + if (!record || !accepted) { + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + `${record?.provider ?? 'This session'} has no session option named ${input.key}` + ) + } + } + const options = { ...record.options, [input.key]: input.value } + await ctx.persistOptions(options) + ctx.publish() + return { ok: true, value: { ...input, options } } +} + +/** A live child's own answer, or the answer at rest; the goal, usage and rewind either way. */ +export async function readStructuredAgentSessionOptions( + context: Pick< + StructuredAgentSessionMutationContext, + 'deps' | 'serialize' | 'openConversation' | 'conversation' + >, + sessionId: string +): Promise { + const { adapter, store } = context.deps + const live = await context.serialize(sessionId, async () => { + const session = await context.openConversation(sessionId).catch((error: unknown) => { + throw journalOpenReadRefusal(error) + }) + const child = session?.child + if (!child) { + return null + } + if (!adapter.readOptions) { + throw new Error('structured_agent_session_options_unsupported') + } + return adapter.readOptions({ sessionId, fence: child.fence }) + }) + const options = live ?? (await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId)) + // Re-acquired after the reads above: the handle they saw may have closed and reopened since. + const session = await context.conversation(sessionId) + const phase = store.getRecord(sessionId)?.rewind?.phase + const agent = session.params.provider + return { + ...options, + rewind: + phase === 'prepared' || phase === 'provider-succeeded' + ? { supported: false, reason: 'outcome-unknown' } + : (adapter.rewindSupport?.(sessionId, agent) ?? { + supported: false, + reason: 'unsupported' + }), + conversationCommands: adapter.compact ? ['clear', 'compact'] : ['clear'], + ...(adapter.supportsThreadGoal?.(sessionId, agent) + ? { threadGoal: { current: session.journal.threadGoal() } } + : {}), + ...(adapter.recordsContextUsage?.(sessionId, agent) + ? { contextUsage: { current: session.journal.contextUsage() } } + : {}) + } +} 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 459cd60bcc3..9da47ae1f22 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 @@ -1,12 +1,12 @@ -// Switching away from a chat starts the release clock, and the clock must never stop a provider -// child that still owes the user work. +// The idle sweep must never stop a provider child that still owes the user work. // // A Claude chat is published before its CLI answers initialize, and a message sent in that window // is accepted and stays queued until it does; the delivery loop hands it over once startup lands. -// Switching away from the chat starts the release clock; the clock must treat that queued message -// as work still owed, exactly as it treats a running turn, or it evicts the session and rejects a -// message the user already sent. And a lead whose turn has settled can leave subagents, commands -// and monitors running inside the child; evicting then ends them silently. +// The idle sweep ticks meanwhile. Inside the idle window it must leave that queued message and the +// starting child alone, and every journal publish — the handover included — starts the window +// again; only a chat quiet for the whole window loses its agent. And a lead whose turn has settled +// can leave subagents, commands and monitors running inside the child; stopping it then ends them +// silently. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -31,8 +31,8 @@ import { } from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } -const SURFACE = 'desktop-chat:1' -const GRACE_MS = 5 +const SWEEP_MS = 5 +const IDLE_MS = 1_000 let root: string let store: AgentSessionRecordStore @@ -41,12 +41,14 @@ let adapter: ClaudeStructuredSessionAdapter let claude: ReturnType let landInit: () => void let lifecycle: Promise[] +let clock: number beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-owed-work-release-')) resetHostTestOperationIds() claude = fakeClaude() lifecycle = [] + clock = NOW const initLanded = new Promise((resolve) => { landInit = resolve }) @@ -89,8 +91,8 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - releaseGraceMs: GRACE_MS, - now: () => NOW + idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, + now: () => clock }) }) @@ -113,7 +115,6 @@ async function attachStarting(): Promise { }) ) expect(created).toMatchObject({ ok: true }) - await host.hold(SESSION, SURFACE) } async function send(text: string, dispatchState = 'pending'): Promise { @@ -135,36 +136,36 @@ async function send(text: string, dispatchState = 'pending'): Promise { return sent.ok ? sent.value.clientMessageId : '' } -function dispatchState(clientMessageId: string): string | undefined { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId)?.dispatchState +async function dispatchState(clientMessageId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + )?.dispatchState } /** The delivery loop hands a message over on its own serialized steps after startup lands; this - * yields to them without advancing the (possibly faked) release clock. */ + * yields to them without moving the host clock. */ async function untilSent(connection: { sent: unknown[] }): Promise { for (let turn = 0; turn < 2000 && connection.sent.length === 0; turn += 1) { await new Promise((resolve) => setImmediate(resolve)) } } -/** Long enough for several grace windows to elapse, so "not evicted" means the clock declined. */ -function waitOutSeveralGraceWindows(): Promise { - return new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) +/** Long enough for many sweep ticks, so "not stopped" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) } -describe('a chat left while its Claude CLI is still starting', () => { - it('keeps the session for a message still queued, and delivers it once startup lands', async () => { +describe('a Claude chat whose CLI is still starting', () => { + it('keeps a message queued inside the idle window, and delivers it once startup lands', async () => { await attachStarting() const held = await send('sent while starting') - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() + clock += IDLE_MS - 1 + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(claude.connections[0].closeCount).toBe(0) - expect(dispatchState(held)).toBe('pending') + expect(await dispatchState(held)).toBe('pending') landInit() await adapter.awaitStarted(SESSION) @@ -173,11 +174,10 @@ describe('a chat left while its Claude CLI is still starting', () => { () => expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]), { timeout: 3000 } ) - await vi.waitFor(() => expect(dispatchState(held)).toBe('accepted')) + await vi.waitFor(async () => expect(await dispatchState(held)).toBe('accepted')) }) - it('gives the message it wrote at startup a full grace to open its turn', async () => { - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + it('gives the message it hands over at startup a full idle window to open its turn', async () => { await attachStarting() const held = await send('sent while starting') const connection = claude.connections[0] @@ -185,33 +185,34 @@ describe('a chat left while its Claude CLI is still starting', () => { connection.send = async (message) => { connection.sent.push(message) } - host.release(SESSION, SURFACE) - await vi.advanceTimersByTimeAsync(GRACE_MS * 3 - 1) + clock += IDLE_MS - 1 + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - // Startup lands just before the clock's next tick. + // Startup lands just before the window closes; the handover's publish starts it again. landInit() await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) await untilSent(connection) expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) - await vi.advanceTimersByTimeAsync(GRACE_MS - 1) + clock += IDLE_MS - 1 + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(connection.closeCount).toBe(0) connection.handlers.onMessage?.(connection.sent[0]) await host.flushStreamedEvents(SESSION) - await vi.advanceTimersByTimeAsync(GRACE_MS * 3) + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - expect(dispatchState(held)).toBe('accepted') + expect(await dispatchState(held)).toBe('accepted') }) - it('is released after the grace once its turn has finished', async () => { + it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { await attachStarting() landInit() await adapter.awaitStarted(SESSION) const answered = await send('answered') - await vi.waitFor(() => expect(dispatchState(answered)).toBe('accepted')) + await vi.waitFor(async () => expect(await dispatchState(answered)).toBe('accepted')) claude.connections[0].handlers.onMessage?.({ type: 'result', subtype: 'success', @@ -221,25 +222,25 @@ describe('a chat left while its Claude CLI is still starting', () => { result: 'done' }) await host.flushStreamedEvents(SESSION) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) - host.release(SESSION, SURFACE) + clock += IDLE_MS await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) expect(claude.connections[0].closeCount).toBe(1) }) - it('is released after the grace when it owes nothing', async () => { + it('stops a start that stayed quiet for the whole window when it owes nothing', async () => { await attachStarting() - host.release(SESSION, SURFACE) + clock += IDLE_MS await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) expect(claude.connections[0].closeCount).toBe(1) }) }) -describe('a chat left while its settled lead still has background work running', () => { +describe('a chat whose settled lead still has background work running', () => { function frame(message: Record): void { claude.connections[0].handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) } @@ -249,7 +250,7 @@ describe('a chat left while its settled lead still has background work running', landInit() await adapter.awaitStarted(SESSION) const fanOut = await send('fan out') - await vi.waitFor(() => expect(dispatchState(fanOut)).toBe('accepted')) + await vi.waitFor(async () => expect(await dispatchState(fanOut)).toBe('accepted')) frame({ type: 'system', subtype: 'task_started', @@ -269,11 +270,11 @@ describe('a chat left while its settled lead still has background work running', ['a subagent', 'local_agent'], ['a background command', 'local_bash'], ['a monitor', 'monitor'] - ])('keeps the session while %s runs, then releases it once that settles', async (_, type) => { + ])('keeps the agent while %s runs, then stops it once that settles', async (_, type) => { await settleTurnLeavingTask(type) - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() + clock += IDLE_MS + await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) expect(claude.connections[0].closeCount).toBe(0) @@ -288,6 +289,7 @@ describe('a chat left while its settled lead still has background work running', // A finished background task can wake the lead; that turn is owed too until it settles. frame({ type: 'result', subtype: 'success', uuid: 'result-2', is_error: false, result: 'ok' }) await host.flushStreamedEvents(SESSION) + clock += IDLE_MS await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) expect(claude.connections[0].closeCount).toBe(1) 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 new file mode 100644 index 00000000000..89772d45469 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -0,0 +1,185 @@ +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 { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { mapRuntimeError } from '../../runtime/rpc/errors' +import { + AgentSessionPreSpawnError, + type AgentSessionPreSpawnReason, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' +import { performAttach } from './structured-agent-session-attach-flow' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +let root: string | null = null + +afterEach(async () => { + vi.restoreAllMocks() + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function createParams(): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + runtimeKind: 'native' + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +/** 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 unused = async (): Promise => { + throw new Error('not reached before a spawn') + } + const adapter: StructuredAgentSessionAdapter = { + acquire: async () => { + throw thrown + }, + dispatch: unused, + cancelTurn: unused, + answerPrompt: unused, + setOption: unused + } + const input = { + store, + adapter, + journalRoot: root, + openConversation: openTestAttachConversation(root), + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' as const } + }, + callerKey: 'client-1', + params: createParams(), + now: () => NOW, + onAttached: () => {} + } + const first = await performAttach(input).then( + () => null, + (error: unknown) => error + ) + expect(first).toBeInstanceOf(AgentSessionPreSpawnError) + return { + first: mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, first), + replay: await performAttach(input) + } +} + +describe('a create that fails before any process spawns', () => { + it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ + [ + 'the managed account env override', + 'This Claude launch defines explicit Anthropic auth environment variables.', + 'managedAccountEnvOverride', + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + ], + [ + 'an account switch in progress', + 'A Claude account switch is in progress. Try again after it finishes.', + 'accountSwitchInProgress', + 'A Claude account switch is in progress. Try again after it finishes.' + ], + [ + 'a Claude account added in WSL', + 'structured Claude is not offered under the active managed Claude account', + 'managedAccountUnsupported', + 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' + ], + [ + "Orca's own reason", + 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', + undefined, + "Claude couldn't start. Send your message to try again." + ] + ])('answers %s first in the words its replay reads', async (_, raw, reason, sentence) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const { first, replay } = await firstAnswerAndReplay( + new AgentSessionPreSpawnError(new Error(raw), reason ? { reason } : {}) + ) + + // Thrown as before: the wire code is unchanged, only the words are the replay's. + expect(first).toMatchObject({ ok: false, error: { code: 'runtime_error', message: sentence } }) + expect(replay).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: sentence, + ...(reason ? { details: { reason } } : {}) + } + }) + if (!reason) { + expect(replay).not.toHaveProperty('refusal.details') + } + if (raw !== sentence) { + expect(JSON.stringify([first, replay])).not.toContain(raw) + } + // What failed is kept for the log. + expect(warn).toHaveBeenCalledWith( + '[agent-session] provider start failed:', + expect.objectContaining({ message: raw }) + ) + }) + + it('keeps a message that is itself a code, and the wire code it maps to', async () => { + const { first } = await firstAnswerAndReplay( + new AgentSessionPreSpawnError(new Error('runtime_unavailable')) + ) + + expect(first).toMatchObject({ + ok: false, + error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } + }) + }) + + it('keeps the situation of a pre-spawn error it wraps', () => { + const typed = new AgentSessionPreSpawnError(new Error('switching'), { + reason: 'accountSwitchInProgress' + }) + + expect(new AgentSessionPreSpawnError(typed).reason).toBe('accountSwitchInProgress') + }) +}) 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 d1ab41ccaef..348d5a3d90c 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 @@ -240,7 +240,7 @@ describe('processless structured session reservation', () => { now: () => NOW, onAttached: () => {} }) - ).rejects.toThrow('workspace no longer exists') + ).rejects.toThrow("Codex couldn't restart. Send your message to try again.") expect(settlement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ exitProof: 'processless', spawnToken: 'spawn-a' }) ) @@ -308,7 +308,9 @@ describe('processless structured session reservation', () => { onAttached: () => {} } - await expect(performAttach(input)).rejects.toThrow('launch not ready') + await expect(performAttach(input)).rejects.toThrow( + "Codex couldn't restart. Send your message to try again." + ) await expect(performAttach(input)).resolves.toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts index 7f71a9c28ce..7324e3be9ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-state.ts @@ -2,7 +2,7 @@ import type { AgentJournalItemBody, AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionTurnContext } from './structured-agent-session-turns' type PendingPromptBody = Extract @@ -11,8 +11,12 @@ export type PendingPromptValidation = | { ok: true; item: AgentJournalRenderItem; prompt: PendingPromptBody } | { ok: false; refusal: AgentSessionWireRefusal } -function invalid(message: string): PendingPromptValidation { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +/** The prompt the client named is not one waiting on the user: nothing to answer. */ +function promptGone(message: string): PendingPromptValidation { + return { + ok: false, + refusal: refuse('agent_session_operation_invalid', { reason: 'promptGone' }, message) + } } export function validatePendingPrompt( @@ -25,34 +29,36 @@ export function validatePendingPrompt( ): PendingPromptValidation { const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) if (!item) { - return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + return promptGone(`No item ${input.itemId} in session ${ctx.sessionId}.`) } const prompt = item.body.kind === 'approval' || item.body.kind === 'question' ? item.body : null if (!prompt || (input.kind !== undefined && prompt.kind !== input.kind)) { - return invalid( + return promptGone( `Item ${input.itemId} is not a pending${input.kind ? ` ${input.kind}` : ' prompt'}.` ) } if (item.revision !== input.expectedRevision) { return { ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - message: `Item ${input.itemId} has moved on.`, - currentRevision: item.revision, - resolution: prompt.resolution - } + refusal: refuse( + 'agent_session_item_revision_stale', + { reason: 'promptMoved', currentRevision: item.revision, resolution: prompt.resolution }, + `Item ${input.itemId} has moved on.` + ) } } if (prompt.resolution.state !== 'pending') { return { ok: false, - refusal: { - code: 'agent_session_already_resolved', - message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, - currentRevision: item.revision, - resolution: prompt.resolution - } + refusal: refuse( + 'agent_session_already_resolved', + { + reason: 'promptAlreadyResolved', + currentRevision: item.revision, + resolution: prompt.resolution + }, + `Item ${input.itemId} was already ${prompt.resolution.state}.` + ) } } return { ok: true, item, prompt } 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 10d60791781..fb66a1e1a8f 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 @@ -12,19 +12,19 @@ import type { AgentSessionStatusSummary, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { - DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTED_PROVIDER_CLOSED -} from '../../../shared/structured-agent-session-dispatch-rejection' -import { - providerStartupFailureOutcome, - unexpectedProviderExitOutcome -} from './structured-agent-session-dead-generation-settlement' + agentSessionFailureFact, + type AgentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +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 { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { @@ -91,7 +91,6 @@ function startHost(): void { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - releaseGraceMs: 60_000, now: () => NOW }) } @@ -167,18 +166,28 @@ function stop() { }) } -function submission(id: string): AgentJournalSubmission | undefined { - return host.journalSnapshot(SESSION).submissions.find((entry) => entry.clientMessageId === id) +async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) } -function statusRows(): { itemId: string; text: string; tone?: string }[] { - return host.journalSnapshot(SESSION).items.flatMap((item) => +async function statusRows(): Promise< + { + itemId: string + text: string + tone?: string + failure?: AgentSessionFailureFact + }[] +> { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => item.body.kind === 'status' ? [ { itemId: item.itemId, text: item.body.text, - ...(item.body.tone ? { tone: item.body.tone } : {}) + ...(item.body.tone ? { tone: item.body.tone } : {}), + ...(item.body.failure ? { failure: item.body.failure } : {}) } ] : [] @@ -199,6 +208,8 @@ function exit(child: ReturnType, reason: string, startupUnp type: 'ended', ...child, reason, + // As an adapter reports it: the exit's stderr as a log detail. + failure: { kind: 'providerExited', detail: { text: reason, audience: 'log' } }, cause: 'unexpected-exit', ...(startupUnproven ? { startupUnproven } : {}) }) @@ -218,9 +229,9 @@ function conversation() { return host['sessions'].get(SESSION) } -function subscribe(): AgentSessionSubscribeEvent[] { +async function subscribe(): Promise { const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ + await host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(structuredClone(event)) @@ -251,7 +262,7 @@ function deferred() { } describe('Stop on a child still proving its start', () => { - it('ends the child and keeps the conversation, its holders and its readers (R1)', async () => { + it('ends the child and keeps the conversation and its readers (R1)', async () => { const ended = deferred() adapterExtras = { awaitStarted: vi.fn(() => ended.promise), @@ -262,32 +273,30 @@ describe('Stop on a child still proving its start', () => { } await restartHost() acquire.mockImplementationOnce(spawnStartingChild) - await host.hold(SESSION, 'surface-1', { resume: false }) const first = await accept('hello') const journal = conversation()?.journal - const events = subscribe() + const events = await subscribe() const frames = watchStatus() await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - // The same conversation: no reopen, the holder kept, and the chat told it is idle again. + // The same conversation: no reopen, and the chat told it is idle again. expect(conversation()?.journal).toBe(journal) expect(conversation()?.child).toBeNull() expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', rootGone: true }) - expect(host.isHeld(SESSION)).toBe(true) expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') - expect(submission(first)).toMatchObject({ + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) // A Stop is not a failure: no row, and the loop is gone. - expect(statusRows()).toEqual([]) + expect(await statusRows()).toEqual([]) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) const next = await accept('after stop') - await eventually(() => expect(submission(next)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) expect(conversation()?.journal).toBe(journal) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) // The reader opened before the Stop saw the next message delivered on the same stream. @@ -335,7 +344,7 @@ describe('settling an earlier child before the next one takes its message', () = await journal.close() const id = await accept('for the next child') - await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) // The exit was observed, so its receipt ends the turn, and the chat says why it stopped. const items = conversation()!.journal.snapshot().items expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( @@ -347,51 +356,70 @@ describe('settling an earlier child before the next one takes its message', () = ) expect( items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toContain(unexpectedProviderExitOutcome('provider exited')) + ).toContain( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) // Handed over at the new child's fence, which the attach reserved after settling. const newFence = store.getRecord(SESSION)!.lease.runtimeFence expect(newFence).toBeGreaterThan(releasedFence) - expect(submission(id)?.fence).toBe(newFence) + expect((await submission(id))?.fence).toBe(newFence) expect(conversation()?.child).toMatchObject({ generation: generation(), fence: newFence }) }) }) +const START_EXIT = 'claude stream-json exited (code 1)' +const START_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' +const START_FAILURE: SubmissionRejectionFact = { + kind: 'providerStartFailed', + detail: { text: START_EXIT, audience: 'log' } +} + describe('a published child that dies while it proves its start', () => { - const EXIT = 'claude stream-json exited (code 1)' - const TEXT = providerStartupFailureOutcome(EXIT) + const EXIT = START_EXIT + const TEXT = START_TEXT it.each([['the loop sees the start fail first'], ['the exit is processed first']])( 'leaves one error row keyed by the start, and every queued message rejected with it: %s (R2)', async (order) => { - const settled = deferred() + const settled = deferred() adapterExtras = { awaitStarted: vi.fn(() => settled.promise) } await restartHost() acquire.mockImplementation(spawnStartingChild) const first = await accept('first') - const events = subscribe() + const events = await subscribe() const second = await accept('second') await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalled()) const child = currentChild() if (order === 'the exit is processed first') { await exit(child, EXIT, true) - settled.resolve(TEXT) + settled.resolve(START_FAILURE) } else { - settled.resolve(TEXT) - await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) + settled.resolve(START_FAILURE) + await eventually(async () => + expect((await submission(second))?.dispatchState).toBe('rejected') + ) await exit(child, EXIT, true) } - await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) - expect(statusRows()).toEqual([ + await eventually(async () => + expect((await submission(second))?.dispatchState).toBe('rejected') + ) + expect(await statusRows()).toEqual([ { itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, text: TEXT, - tone: 'error' + tone: 'error', + failure: START_FAILURE } ]) - expect(submission(first)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) - expect(submission(second)).toMatchObject({ dispatchState: 'rejected', reason: TEXT }) + for (const id of [first, second]) { + expect(await submission(id)).toMatchObject({ + dispatchState: 'rejected', + reason: TEXT, + rejection: START_FAILURE + }) + } expect(rejectedIn(events, second)).toBe(true) expect(dispatch).not.toHaveBeenCalled() expect(acquire).toHaveBeenCalledTimes(2) @@ -399,23 +427,30 @@ describe('a published child that dies while it proves its start', () => { ) }) -describe("a view's start that dies while a sent message waits on it", () => { - const EXIT = 'claude stream-json exited (code 1)' - const TEXT = providerStartupFailureOutcome(EXIT) +/** A start by an operation that needs the agent, such as a goal change, outside the loop. */ +function startForOperation() { + return host['serialize'](SESSION, () => + ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) + ) +} + +describe('a start another operation made that dies while a sent message waits on it', () => { + const EXIT = START_EXIT + const TEXT = START_TEXT it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { - adapterExtras = { awaitStarted: vi.fn(async () => TEXT) } + adapterExtras = { awaitStarted: vi.fn(async () => START_FAILURE) } await restartHost() acquire.mockImplementation(spawnStartingChild) - // Opening the tab: the view's hold starts a child that has not proven its start. - await host.hold(SESSION, 'surface-1') - const viewChild = currentChild() - const events = subscribe() + // An operation that needs the agent starts a child that has not proven its start. + await startForOperation() + const operationChild = currentChild() + const events = await subscribe() const params = sendParams('hello') - // Accepted first; the view's child's exit is settled before the loop's first step. + // Accepted first; that child's exit is settled before the loop's first step. const sent = host.send(CALLER, params) - const exited = exit(viewChild, EXIT, true) + const exited = exit(operationChild, EXIT, true) expect(await sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } @@ -423,18 +458,19 @@ describe("a view's start that dies while a sent message waits on it", () => { await exited const id = params.envelope.clientOperationId - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) await settleLoop() - expect(submission(id)?.reason).toBe(TEXT) - expect(statusRows()).toEqual([ + expect((await submission(id))?.reason).toBe(TEXT) + expect(await statusRows()).toEqual([ { - itemId: `orca:${encodeURIComponent(`start-failure:${viewChild.acquisitionGeneration}`)}`, + itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, text: TEXT, - tone: 'error' + tone: 'error', + failure: START_FAILURE } ]) expect(rejectedIn(events, id)).toBe(true) - // The setup's child and the view's: nothing started again into the same failure. + // The setup's child and the operation's: nothing started again into the same failure. expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() }) @@ -442,19 +478,19 @@ describe("a view's start that dies while a sent message waits on it", () => { it('leaves a message sent after that start failed to a fresh start (R2)', async () => { await restartHost() acquire.mockImplementationOnce(spawnStartingChild) - await host.hold(SESSION, 'surface-1') + await startForOperation() await exit(currentChild(), EXIT, true) - expect(statusRows()).toHaveLength(1) + expect(await statusRows()).toHaveLength(1) const id = await accept('after the failure') - await eventually(() => expect(submission(id)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(acquire).toHaveBeenCalledTimes(3) }) it('starts again for a message whose proven child crashed: only a failed start settles it (R2)', async () => { await restartHost() - await host.hold(SESSION, 'surface-1') + await startForOperation() const params = sendParams('hello') const sent = host.send(CALLER, params) @@ -463,8 +499,8 @@ describe("a view's start that dies while a sent message waits on it", () => { await exited const id = params.envelope.clientOperationId - await eventually(() => expect(submission(id)?.dispatchState).not.toBe('pending')) - expect(submission(id)?.dispatchState).toBe('accepted') + await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) + expect((await submission(id))?.dispatchState).toBe('accepted') expect(acquire).toHaveBeenCalledTimes(3) }) @@ -472,22 +508,20 @@ describe("a view's start that dies while a sent message waits on it", () => { adapterExtras = { awaitStarted: vi.fn(async () => undefined) } await restartHost() acquire.mockImplementation(spawnStartingChild) - await host.hold(SESSION, 'surface-1') + await startForOperation() const params = sendParams('hello') - // A client's attach lands after the first child's exit, before the loop's first step: a view - // no longer starts a child whose last start failed, but an attach still does. + // A second operation's start lands after the first child's exit, before the loop's first step. const sent = host.send(CALLER, params) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const exited = exit(currentChild(), EXIT, true) - const attached = host.attach(CALLER, hostTestAttachParams(exitedFence + 1)) + const held = startForOperation() await sent await exited - await expect(attached).resolves.toMatchObject({ ok: true }) + await held const id = params.envelope.clientOperationId - await eventually(() => expect(submission(id)?.dispatchState).not.toBe('pending')) - expect(submission(id)?.dispatchState).toBe('accepted') + await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) + expect((await submission(id))?.dispatchState).toBe('accepted') expect(acquire).toHaveBeenCalledTimes(3) }) }) @@ -502,12 +536,20 @@ describe('a child that ends before its message is handed over', () => { } await restartHost() const id = await accept('hello') - const events = subscribe() + const events = await subscribe() - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) - expect(submission(id)?.reason).toContain('codex app-server crashed') - expect(statusRows()).toEqual([ - { itemId: expect.any(String), text: submission(id)?.reason, tone: 'error' } + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + // The exit's stderr rides beside the sentence, never in it. + const failure = { + kind: 'providerExited', + detail: { text: 'codex app-server crashed', audience: 'log' } + } + expect(await submission(id)).toMatchObject({ + reason: 'Codex stopped before this message was sent.', + rejection: failure + }) + expect(await statusRows()).toEqual([ + { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } ]) expect(rejectedIn(events, id)).toBe(true) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) @@ -536,8 +578,10 @@ describe('another child indexed while the loop waits on the one it started', () const stopped = currentChild() expect(await stop()).toMatchObject({ ok: true }) const second = await accept('second') - // A view's hold starts its own child before the loop's handover step runs. - await host.hold(SESSION, 'surface-1') + // An operation that needs the agent starts its own child before the loop's handover step runs. + await host['serialize'](SESSION, () => + ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) + ) const replacement = currentChild() expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) @@ -553,9 +597,9 @@ describe('another child indexed while the loop waits on the one it started', () }) startOf(replacement.acquisitionGeneration).resolve() - await eventually(() => expect(submission(second)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([second]) - expect(submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) + expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) }) }) @@ -564,7 +608,7 @@ describe('a quit with a message still queued', () => { async function afterRelaunch(id: string): Promise { startHost() await host.revealSession(SESSION) - return submission(id) + return await submission(id) } it('settles a message no child ever had the way a chat close does (R2)', async () => { @@ -576,7 +620,7 @@ describe('a quit with a message still queued', () => { expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) }) @@ -604,7 +648,7 @@ describe('a quit with a message still queued', () => { expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_PROVIDER_CLOSED + ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) }) }) }) @@ -620,7 +664,7 @@ describe('a send whose start failed, sent again with the same operation id', () value: { submission: { dispatchState: 'pending' } } }) const id = params.envelope.clientOperationId - await eventually(() => expect(submission(id)?.dispatchState).toBe('rejected')) + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) // The start moved the fence while the message was out. expect(store.getRecord(SESSION)!.lease.runtimeFence).toBeGreaterThan(fenceBefore) const starts = acquire.mock.calls.length @@ -667,13 +711,13 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await stop()).toMatchObject({ ok: true }) }) - await eventually(() => expect(submission(second)?.dispatchState).toBe('accepted')) + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', reason: null }) - expect(statusRows()).toEqual([]) + expect(await statusRows()).toEqual([]) }) - it('fails the start after a host stop, with the stop as the reason (R2)', async () => { - const reason = 'Claude never finished starting, so Orca stopped it.' + it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { + const reason = 'the start watchdog fired' const second = await stoppedWhileStarting(() => host['serialize'](SESSION, () => stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { @@ -683,9 +727,16 @@ describe('how a stopped child ends the start its loop was waiting on', () => { ) ) - await eventually(() => expect(submission(second)?.dispatchState).toBe('rejected')) - expect(submission(second)?.reason).toBe(reason) - expect(statusRows()).toEqual([{ itemId: expect.any(String), text: reason, tone: 'error' }]) + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) + // The sentence is the constructor's, not the reason the stop was given. + const text = 'Codex never finished starting, so Orca stopped it.' + expect(await submission(second)).toMatchObject({ + reason: text, + rejection: { kind: 'hostStopped' } + }) + expect(await statusRows()).toEqual([ + { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } + ]) expect(dispatch).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index 6c469e4dacc..da5e491e1d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -48,13 +48,18 @@ export function markProviderChildStarted( export function endProviderChild( session: ChildBearer, - ended: Omit + ended: Omit ): boolean { - if (!matchingChild(session, ended)) { + const child = matchingChild(session, ended) + if (!child) { return false } session.child = null - session.lastEndedChild = { ...ended, endedAt: session.journal.cursor() } + session.lastEndedChild = { + ...ended, + ...(child.startedFor === undefined ? {} : { startedFor: child.startedFor }), + endedAt: session.journal.cursor() + } return true } 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 c8a2e4bba14..5da0861aa19 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 @@ -5,12 +5,15 @@ import { afterEach, describe, expect, it } from 'vitest' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' import { HOST_TEST_NOW, HOST_TEST_SESSION, hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -34,7 +37,12 @@ function claudeAdapter(): StructuredAgentSessionAdapter { observedAt: HOST_TEST_NOW } }), - dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + dispatch: async () => ({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + }), cancelTurn: async () => ({ cancelled: false }), answerPrompt: async () => undefined, setOption: async () => undefined @@ -58,15 +66,8 @@ function createHost( return host } -async function abandonHost(host: StructuredAgentSessionHost): Promise { - host['runtimeState'].stopLeaseRenewal() - host['holds'].dispose() - await Promise.all([...host['sessions'].values()].map((session) => session.journal.close())) - host['sessions'].clear() -} - afterEach(async () => { - await Promise.all(hosts.splice(0).map(abandonHost)) + await Promise.all(hosts.splice(0).map(abandonStructuredAgentSessionHost)) await rm(root, { recursive: true, force: true }) root = '' }) 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 bb5d29d4dea..7c195a9a124 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 @@ -80,8 +80,8 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -function claudeParams() { - return hostTestAttachParams(null, { +function claudeParams(expectedRuntimeFence: number | null = null) { + return hostTestAttachParams(expectedRuntimeFence, { provider: 'claude', agent: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, @@ -131,8 +131,10 @@ describe('a publish-first Claude create whose init is slow', () => { await host.close(SESSION) const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - // Reopening the chat: the surface's first hold resumes the session. - await host.hold(SESSION, 'chat-1') + // Starting the chat again resumes the session under a new fence. + await expect(host.attach(CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ + ok: true + }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) // The new child's init reports its CLI default; the saved pick is restored over it. expect(store.getRecord(SESSION)?.options?.model).toBe('opus') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 6d6d910cc55..8f3c0390aaa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -24,7 +24,6 @@ export type StructuredAgentSessionProviderStartedContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus?: (sessionId: string) => void - restartReleaseGrace: (sessionId: string) => void onBarrierError: (sessionId: string, error: unknown) => void } @@ -44,9 +43,6 @@ export function settleStructuredAgentSessionProviderStarted( ) { return } - // Prompts held for the start are written now but open a turn only on their echo; a release - // tick in between would stop the child before it runs them. - context.restartReleaseGrace(event.sessionId) try { await persistStartedOptions(context, event) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts index ed10f63e44c..06bd043aec0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts @@ -1,9 +1,6 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionReadRestoreDeps } from './structured-agent-session-restart-restore' -import { - restoreOneStructuredAgentSessionRead, - restoreStructuredAgentSessionsOnRestart -} from './structured-agent-session-restart-restore' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' export class StructuredAgentSessionReadableRestorer { private restorePromise: Promise | null = null @@ -22,30 +19,6 @@ export class StructuredAgentSessionReadableRestorer { return this.restorePromise } - /** - * One session, on demand, after startup. - * - * Deliberately outside `restorePromise`: that latch answers "has the startup sweep run", and a - * surface asking for a session the sweep never covered — or that was closed since — must not be - * told yes because the sweep finished. Needs no dedupe of its own; the per-session task queue - * `serialize` runs on already orders concurrent callers, and the second one sees `hasSession`. - * - * Provider-agnostic by construction: eligibility is `supportsRecord`, which the adapter router - * answers for Claude and Codex from the record's own provider. - */ - async restoreOne(sessionId: string): Promise { - if (!this.supports(sessionId)) { - return false - } - await restoreOneStructuredAgentSessionRead(this.input, sessionId) - return this.input.hasSession(sessionId) - } - - private supports(sessionId: string): boolean { - const record = this.input.openDeps.store.getRecord(sessionId) - return record !== null && this.input.supportsRecord(record) - } - private async restoreReadableSessions(sessionIds?: readonly string[]): Promise { const targetOrder = sessionIds ? new Map(sessionIds.map((sessionId, index) => [sessionId, index])) 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 ff2492c5876..2da22f95873 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 @@ -11,6 +11,7 @@ import { readProcessStartTimeMs } from '../../runtime/agent-session-process-iden import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-owner-probe' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { HOST_TEST_NOW as NOW, @@ -59,6 +60,12 @@ async function stopOwner(child: ReturnType): Promise spawnedOwners.delete(child) } +/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ +async function startAgent(): Promise { + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect((await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) +} + function adapter(): StructuredAgentSessionAdapter { return { acquire, @@ -82,17 +89,8 @@ function openHost(overrides: Partial = {}): void }) } -async function abandonHost(abandonedHost: StructuredAgentSessionHost): Promise { - abandonedHost['runtimeState'].stopLeaseRenewal() - abandonedHost['holds'].dispose() - await Promise.all( - [...abandonedHost['sessions'].values()].map((session) => session.journal.close()) - ) - abandonedHost['sessions'].clear() -} - async function reopenStore(): Promise { - await abandonHost(host) + await abandonStructuredAgentSessionHost(host) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) } @@ -119,8 +117,8 @@ beforeEach(async () => { }) afterEach(async () => { - await abandonHost(host) - await Promise.all([...supersededHosts].map(abandonHost)) + await abandonStructuredAgentSessionHost(host) + await Promise.all([...supersededHosts].map(abandonStructuredAgentSessionHost)) supersededHosts.clear() await Promise.all([...spawnedOwners].map((child) => stopOwner(child))) await rm(root, { recursive: true, force: true }) @@ -195,8 +193,8 @@ describe('recovery exits', () => { runtimeFence: 4 }) - // The ordinary native recovery path remains: the first surface hold resumes it. - await host.hold(SESSION, 'surface-1') + // The ordinary native recovery path remains: the next start resumes it. + await startAgent() expect(acquire).toHaveBeenCalledTimes(3) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', @@ -240,7 +238,7 @@ describe('recovery exits', () => { }) }) - it('heals a stranded native owner during startup restore, and spawns nothing until a surface asks', async () => { + it('heals a stranded native owner during startup restore, and spawns nothing until work asks', async () => { expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) await reopenStore() @@ -260,7 +258,7 @@ describe('recovery exits', () => { await host.restoreReadableSessions() // Healing is startup's job; spawning is not. The orphan is stopped and the lease is free, but - // nothing has asked to look at this session, so no replacement child exists yet. + // nothing has asked this session for work, so no replacement child exists yet. expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') expect(acquire).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -269,7 +267,7 @@ describe('recovery exits', () => { ownerProcess: null }) - await host.hold(SESSION, 'surface-1') + await startAgent() expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -338,7 +336,7 @@ describe('recovery exits', () => { } }) - await host.hold(SESSION, 'surface-overlap') + await startAgent() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeFence: 3, @@ -346,6 +344,6 @@ describe('recovery exits', () => { handoffStage: null, ownerProcess: { pid: replacement.process.pid, spawnToken: 'spawn-b' } }) - expect(host.history({ sessionId: SESSION, direction: 'tail' }).ok).toBe(true) + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts new file mode 100644 index 00000000000..505bec74c23 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-details.test.ts @@ -0,0 +1,274 @@ +// A refusal's details name the situation, so every place that answers for a refusal — the first +// reply, a ledger replay, the store fallback copy — must name the same one. + +import { describe, expect, it } from 'vitest' +import { + agentSessionRecordFixture, + agentSessionLeaseFixture +} from '../../../shared/agent-session-record.test-fixture' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { classifyStoreFailure } from './structured-agent-session-attach' +import { + AgentSessionAcquisitionExitProvenError, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError +} from './structured-agent-session-adapter' +import { + failedAcquisitionRefusal, + failedAcquisitionSettlement +} from './structured-agent-session-failed-create-refusal' +import { withObservedProviderExit } from './structured-agent-session-failure-text' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' + +const CLAUDE_CREATE = { + record: agentSessionRecordFixture(), + newSession: true +} + +function replay(outcome: Parameters[0]['outcome']) { + return resolveAgentSessionReplayOutcome({ + operationId: 'op-1', + outcome, + reconstruct: () => null + }) +} + +describe('a ledger replay names the details its first answer did', () => { + it.each([ + [new AgentSessionAcquisitionRefusal('not signed in', 'notSignedIn'), 'notSignedIn'], + [ + new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit(new Error('exited (code 1)')) + ), + 'providerStartFailed' + ], + // Gone now, with no exit observed: no situation, on the first answer or the replay. + [new AgentSessionAcquisitionExitProvenError(new Error('spawn codex ENOENT')), undefined] + ])('for a failed create: %s', (error, reason) => { + const first = failedAcquisitionRefusal(error, CLAUDE_CREATE) + const replayed = replay(failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome) + expect(first?.refusal.details?.reason).toBe(reason) + expect(replayed).toMatchObject({ + decision: 'refuse', + refusal: { code: first?.refusal.code, ...(reason ? { details: first?.refusal.details } : {}) } + }) + if (!reason) { + expect(replayed).not.toHaveProperty('refusal.details') + } + }) + + it('for a create whose cleanup could not prove the child gone', () => { + const outcome = failedAcquisitionSettlement( + new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')), + CLAUDE_CREATE + ).outcome + expect(replay(outcome)).toMatchObject({ + refusal: { code: 'agent_session_ownership_unknown', details: { reason: 'ownerUnproven' } } + }) + }) + + it.each([ + [ + new AgentSessionAcquisitionExitProvenError( + withObservedProviderExit( + new Error('claude stream-json exited (code 1): claude: not signed in (rig)') + ) + ), + 'Claude stopped before it finished starting. Send your message to try again.' + ], + [ + new AgentSessionAcquisitionExitProvenError(new Error('spawn claude ENOENT')), + "Claude couldn't start. Send your message to try again." + ], + [ + new AgentSessionAcquisitionRefusal( + 'Claude is not signed in for the selected account. Sign in with the Claude CLI for this CLAUDE_CONFIG_DIR, then retry.', + 'notSignedIn' + ), + 'Claude is not signed in for the selected account. Sign in, then send your message again.' + ], + [ + AgentSessionAcquisitionRefusal.historyTooLarge( + 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' + ), + "This conversation's history is too large to restore here. Start a new chat to continue." + ] + ])('answers and replays %s in the sentence its start failure reads as', (error, sentence) => { + const first = failedAcquisitionRefusal(error, CLAUDE_CREATE) + const replayed = replay(failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome) + expect(first?.refusal.message).toBe(sentence) + expect(replayed).toMatchObject({ decision: 'refuse', refusal: { message: sentence } }) + for (const message of [ + first?.refusal.message, + failedAcquisitionSettlement(error, CLAUDE_CREATE).outcome.message + ]) { + expect(message).not.toMatch( + /stream-json|exited \(code|ENOENT|CLAUDE_CONFIG_DIR|restore queue/ + ) + } + }) + + it('replays a spawn that failed before any process in a sentence, not its error', () => { + const outcome = failedAcquisitionSettlement( + new AgentSessionPreSpawnError(new Error('spawn claude ENOENT')), + CLAUDE_CREATE + ).outcome + expect(replay(outcome)).toMatchObject({ + refusal: { message: "Claude couldn't start. Send your message to try again." } + }) + }) + + it('keeps the code a store refusal replays with, and the unproven-exit marker', () => { + expect( + failedAcquisitionSettlement(new Error('agent_session_conflict'), CLAUDE_CREATE).outcome + .message + ).toBe('agent_session_conflict') + expect( + failedAcquisitionSettlement( + new AgentSessionAcquisitionExitUnprovenError(new Error('probe failed')), + CLAUDE_CREATE + ).outcome.message + ).toBe('agent_session_acquisition_exit_unproven') + }) + + it('replays the facts beside the reason, and mirrors them where released clients read', () => { + const resolution = { + state: 'resolved' as const, + selectedOptionId: 'allow', + resolvedBy: 'phone', + resolvedAt: 5 + } + const replayed = replay({ + status: 'failed', + code: 'agent_session_item_revision_stale', + details: { reason: 'promptMoved', currentRevision: 3, resolution } + }) + expect(replayed).toMatchObject({ + refusal: { + code: 'agent_session_item_revision_stale', + details: { reason: 'promptMoved', currentRevision: 3, resolution }, + currentRevision: 3, + resolution + } + }) + }) + + it('reads a row an older host wrote, with no details, as naming none', () => { + const replayed = replay({ status: 'failed', code: 'agent_session_conflict' }) + expect(replayed).toMatchObject({ refusal: { code: 'agent_session_conflict' } }) + expect(replayed.decision === 'refuse' && replayed.refusal).not.toHaveProperty('details') + }) + + it('drops the cause an unreleased build wrote, and a reason the code does not list', () => { + for (const row of [ + { status: 'failed' as const, code: 'agent_session_conflict', cause: 'claimConflicted' }, + { + status: 'failed' as const, + code: 'agent_session_conflict', + details: { reason: 'promptGone' as const } + } + ]) { + const replayed = replay(row) + expect(replayed).toMatchObject({ refusal: { code: 'agent_session_conflict' } }) + expect(replayed.decision === 'refuse' && replayed.refusal).not.toHaveProperty('details') + } + }) + + it('names a code this build cannot place as refused earlier', () => { + expect(replay({ status: 'failed', code: 'agent_session_future_code' })).toMatchObject({ + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'operationRefusedEarlier' } + } + }) + }) + + it('names a lost outcome and a lost result', () => { + expect(replay({ status: 'unknown' })).toMatchObject({ + refusal: { details: { reason: 'outcomeUnknown' } } + }) + expect(replay({ status: 'succeeded', sessionId: 's' })).toMatchObject({ + refusal: { details: { reason: 'resultLost' } } + }) + }) +}) + +describe('the store fallback copy', () => { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-1' } + }) + ) + + it('words a situation its code would misdescribe by the situation', () => { + const refusal = classifyStoreFailure( + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'replaySuperseded' }), + null, + record + ) + expect(refusal).toMatchObject({ + code: 'agent_session_ownership_unknown', + details: { reason: 'replaySuperseded' } + }) + // Not the latched-owner story: this owner is not in doubt, the replay is just stale. + expect(refusal.message).not.toContain('4242') + }) + + it('keeps the latched-owner story, with its reason, for an owner it cannot prove gone', () => { + const refusal = classifyStoreFailure( + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'ownerUnproven' }), + null, + record + ) + expect(refusal.details?.reason).toBe('ownerUnproven') + expect(refusal.message).toContain('4242') + }) + + it('does not promise an update fixes a record this build cannot read', () => { + // Unreadable covers a damaged record as well as one a newer build wrote. + expect( + classifyStoreFailure( + agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }), + null, + null + ) + ).toEqual({ + code: 'execution_owner_reconciling', + details: { reason: 'recordUnreadable' }, + message: + "Orca can't read this chat's saved state. If a newer version of Orca saved it, update Orca to open it; otherwise start a new chat." + }) + }) + + it('names the latch for a bare code an older path still throws', () => { + expect( + classifyStoreFailure(new Error('agent_session_conflict'), null, { + ...record, + lease: { ...record.lease, claimStatus: 'conflicted' } + }).details + ).toEqual({ reason: 'claimConflicted' }) + expect( + classifyStoreFailure(new Error('agent_session_conflict'), null, null) + ).not.toHaveProperty('details') + }) + + it('puts the current fence in the details of a stale checkpoint, and mirrors it', () => { + expect( + classifyStoreFailure( + agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'fenceStale' }), + 7, + null + ) + ).toEqual({ + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale', currentFence: 7 }, + currentFence: 7, + message: 'The session store refused this call: agent_session_checkpoint_stale.' + }) + // A bare code names no reason, but the fence is still a fact. + expect( + classifyStoreFailure(new Error('agent_session_checkpoint_stale'), 7, null) + ).toMatchObject({ details: { currentFence: 7 }, currentFence: 7 }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts index 9d5cab66e27..7a7a9e289ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts @@ -9,7 +9,10 @@ import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' +import type { + AgentSessionAnyRefusalReason, + AgentSessionRefusalReference +} from '../../../shared/agent-session-wire' function ownerDescription(record: AgentSessionRecord): string { const owner = record.lease.ownerProcess @@ -25,19 +28,63 @@ function latchedMessage(record: AgentSessionRecord): string { : 'Orca cannot tell whether an agent started for this session before the app stopped, so it will not start a second one on the same conversation. Quit any leftover agent process for this workspace and reopen this chat.' } -/** Null when the code has no session-specific story to tell; the caller keeps its own wording. */ +/** + * A situation whose own words the store's code would get wrong: each of these reaches the chat as + * `ownership_unknown` or `conflict`, which by code alone would read as the latched-owner story. + */ +const SITUATION_MESSAGES: Partial> = { + replaySuperseded: 'A newer start of this chat replaced this one. Try again.', + leaseMoved: 'This chat changed hands while Orca was starting it. Try again.', + spawnIdentityMismatch: + 'The agent that started was not the one Orca launched, so Orca did not use it. Try again.', + identityMismatch: + 'This chat belongs to a different workspace, agent or account. Start a new chat to continue.', + sessionExists: 'This chat already exists. Open it to continue.', + tabIdTaken: 'Another chat already uses this tab. Try again.', + conversationHeldElsewhere: + 'Another chat is already working in this conversation. Open that chat to continue.', + // Also a damaged record, which no update opens. + recordUnreadable: + "Orca can't read this chat's saved state. If a newer version of Orca saved it, update Orca to open it; otherwise start a new chat." +} + +/** + * The words and the situation for a store refusal. The reason is what the emitter named, when it + * named one; the latched branches name their own. Null when neither has a story to tell, and the + * caller keeps its own wording. + */ export function structuredAgentSessionRefusalMessage( - code: AgentSessionWireRefusalCode, + emitted: AgentSessionRefusalReference, record: AgentSessionRecord | null -): string | null { +): { message: string; reference: AgentSessionRefusalReference } | null { + const reason = emitted.details?.reason + const situational = reason ? SITUATION_MESSAGES[reason] : undefined + if (situational) { + return { message: situational, reference: emitted } + } if (!record) { return null } + const { code } = emitted if (code === 'agent_session_ownership_unknown' || code === 'agent_session_conflict') { - return latchedMessage(record) + const message = latchedMessage(record) + if (record.lease.claimStatus === 'conflicted') { + return { message, reference: { code, details: { reason: 'claimConflicted' } } } + } + return { + message, + reference: + code === 'agent_session_conflict' && reason === 'ownerAlive' + ? { code, details: { reason: 'ownerAlive' } } + : { code, details: { reason: 'ownerUnproven' } } + } } if (code === 'execution_owner_reconciling') { - return 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.' + return { + message: + 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.', + reference: { code, details: { reason: 'hostReconciling' } } + } } return null } 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 0d873389890..4043e27b30c 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 @@ -19,6 +19,7 @@ import { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -103,17 +104,9 @@ async function createHarness(options: { attached?: boolean } = {}) { return harness } -async function abandonHost(host: StructuredAgentSessionHost): Promise { - host['runtimeState'].stopLeaseRenewal() - host['holds'].dispose() - host['conversationDelivery'].loop.dispose() - await Promise.all([...host['sessions'].values()].map((session) => session.journal.close())) - host['sessions'].clear() -} - afterEach(async () => { const completed = harnesses.splice(0) - await Promise.all(completed.map(async ({ host }) => abandonHost(host))) + await Promise.all(completed.map(async ({ host }) => abandonStructuredAgentSessionHost(host))) await Promise.all(completed.map(async ({ root }) => rm(root, { recursive: true }))) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts deleted file mode 100644 index 91daa5606a8..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts +++ /dev/null @@ -1,85 +0,0 @@ -// The delay between "nothing holds this session and nothing has happened in it" and "stop its -// provider child". -// -// It is an IDLE window, not a short grace. A surface that reconnects — a mobile socket dropping on -// a network switch, a renderer remounting a tab, a worktree switch hiding the pane — releases and -// re-holds, and a send to a chat nobody is looking at restarts its owner; stopping the child soon -// after either costs the user a respawn plus a resume on the next message. And a turn the user -// already asked for must finish: stopping the child mid-answer strands the open turn marker. -// -// So the clock arms when the last holder leaves, every journal write while it is armed starts it -// again, and a tick that finds work still owed — a turn running, a message sent but not yet -// taken by the provider, or a subagent, command or monitor still running — re-arms instead of -// evicting. The child goes only after a full window -// with no holder and no owed work. Quit still stops every child at once. - -export const STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS = 30 * 60_000 - -export type StructuredAgentSessionReleaseClockDeps = { - /** Never evict while work is owed; a true answer re-arms the clock instead. */ - hasOwedWork: (sessionId: string) => boolean - /** Re-checked at fire time: a holder may have arrived while the timer ran. */ - isHeld: (sessionId: string) => boolean - evict: (sessionId: string) => Promise - onError?: (input: { sessionId: string; error: unknown }) => void - graceMs?: number -} - -export class StructuredAgentSessionReleaseClock { - private readonly timers = new Map>() - private readonly graceMs: number - - constructor(private readonly deps: StructuredAgentSessionReleaseClockDeps) { - this.graceMs = deps.graceMs ?? STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS - } - - arm(sessionId: string): void { - this.cancel(sessionId) - const timer = setTimeout(() => { - this.timers.delete(sessionId) - this.fire(sessionId) - }, this.graceMs) - // A pending release must never be the reason a process stays alive at quit. - timer.unref?.() - this.timers.set(sessionId, timer) - } - - /** Activity in an unheld session: the idle window starts over. */ - renew(sessionId: string): void { - if (this.timers.has(sessionId)) { - this.arm(sessionId) - } - } - - cancel(sessionId: string): void { - const timer = this.timers.get(sessionId) - if (timer) { - clearTimeout(timer) - this.timers.delete(sessionId) - } - } - - isArmed(sessionId: string): boolean { - return this.timers.has(sessionId) - } - - dispose(): void { - for (const timer of this.timers.values()) { - clearTimeout(timer) - } - this.timers.clear() - } - - private fire(sessionId: string): void { - if (this.deps.isHeld(sessionId)) { - return - } - if (this.deps.hasOwedWork(sessionId)) { - this.arm(sessionId) - return - } - void this.deps.evict(sessionId).catch((error: unknown) => { - this.deps.onError?.({ sessionId, error }) - }) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts index c81c45dfba5..00fa33a30fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -1,9 +1,11 @@ import { rewindRefusal } from './structured-rewind-refusal' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import { - AGENT_SESSION_WIRE_REFUSAL_CODES, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + agentSessionRefusalFromReference, + readAgentSessionRefusalReference, + refuse, + type AgentSessionRefusalReference, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' export type AgentSessionReplayOutcomeDecision = @@ -23,15 +25,12 @@ export function resolveAgentSessionReplayOutcome(input: { if (outcome.rewindReason) { return { decision: 'refuse', refusal: rewindRefusal(outcome.rewindReason).refusal } } - const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code) - ? (outcome.code as AgentSessionWireRefusalCode) - : 'agent_session_operation_invalid' return { decision: 'refuse', - refusal: { - code, - message: outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` - } + refusal: agentSessionRefusalFromReference( + recordedRefusal(outcome.code, outcome.details), + outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` + ) } } if (outcome.status === 'unknown') { @@ -44,10 +43,11 @@ export function resolveAgentSessionReplayOutcome(input: { } return { decision: 'refuse', - refusal: { - code: 'agent_session_operation_unknown', - message: `The outcome of operation ${operationId} is unknown; it was not run again.` - } + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'outcomeUnknown' }, + `The outcome of operation ${operationId} is unknown; it was not run again.` + ) } } const recorded = input.reconstruct() @@ -60,10 +60,22 @@ export function resolveAgentSessionReplayOutcome(input: { return outcome.status === 'succeeded' ? { decision: 'refuse', - refusal: { - code: 'agent_session_operation_unknown', - message: `Operation ${operationId} succeeded, but its result is no longer reconstructable.` - } + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'resultLost' }, + `Operation ${operationId} succeeded, but its result is no longer reconstructable.` + ) } : { decision: 'rerun' } } + +/** The refusal a failed row recorded, so a replay says what the first answer said. A code this + * build does not know was refused for a reason it cannot name. */ +function recordedRefusal(code: string, details: unknown): AgentSessionRefusalReference { + return ( + readAgentSessionRefusalReference({ code, details }) ?? { + code: 'agent_session_operation_invalid', + details: { reason: 'operationRefusedEarlier' } + } + ) +} 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 new file mode 100644 index 00000000000..3ea116fa361 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -0,0 +1,224 @@ +// A real host over a real store and journal, with a scripted provider and a clock the test moves, +// for the tests of a conversation that outlives its agent. The idle sweep runs on its own short +// interval; a test moves `clock.now` past the idle window and waits for the outcome. + +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 { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionMutationEnvelope, + AgentSessionStatusEvent, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' + +export const REST_TEST_CALLER = { callerKey: 'client-1' } +export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS +export const SWEEP_INTERVAL_MS = 5 + +export type RestTestAdapter = { + acquire: Mock + closeSession: Mock> + dispatch: Mock + acknowledgeSessionRelease: Mock< + NonNullable + > + backgroundTaskState: Mock> + readOptions: Mock> +} + +export type RestTestRig = { + root: string + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + adapter: RestTestAdapter + clock: { now: number } + statusEvents: AgentSessionStatusEvent[] + sink: { publish: Mock; forget: Mock } + /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ + restart: (deps?: Partial) => Promise + dispose: () => Promise +} + +let ordinal = 0 +let generations = 0 + +export function acceptedDispatch(): AgentSessionDispatchOutcome { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: `turn-${ordinal}`, ordinal } + } +} + +export function restTestSend( + text: string, + fence = 1 +): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { + const body = hostTestMessage(text) + return { + body, + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + } + } +} + +/** Every item and submission a reader was sent, whatever frame carried it. */ +export function readerSaw(events: readonly AgentSessionSubscribeEvent[]) { + const items = events.flatMap((event) => + event.type === 'batch' ? event.batch.items : event.type === 'end' ? [] : event.page.items + ) + const submissions = events.flatMap((event) => + event.type === 'batch' ? event.batch.submissions : [] + ) + return { + texts: items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ), + submissions + } +} + +export function collectSubscriber(): { + events: AgentSessionSubscribeEvent[] + emit: (event: AgentSessionSubscribeEvent) => void +} { + const events: AgentSessionSubscribeEvent[] = [] + return { events, emit: (event) => events.push(event) } +} + +export async function createRestTestRig( + deps: Partial = {} +): Promise { + resetHostTestOperationIds() + ordinal = 0 + const root = await mkdtemp(join(tmpdir(), 'orca-rest-')) + const clock = { now: HOST_TEST_NOW } + const statusEvents: AgentSessionStatusEvent[] = [] + const sink = { publish: vi.fn(), forget: vi.fn() } + let store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const adapter: RestTestAdapter = { + acquire: vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `generation-${++generations}`, + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: clock.now + } + })), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(async () => acceptedDispatch()), + acknowledgeSessionRelease: vi.fn(), + backgroundTaskState: vi.fn(() => undefined), + readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) + } + const hostFor = (overrides: Partial) => + new StructuredAgentSessionHost({ + store, + adapter: { + ...adapter, + releaseAcquisition: vi.fn(async () => true), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async ({ commit }) => commit(), + setOption: async () => undefined + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => clock.now, + statusSink: sink, + idleSweep: { intervalMs: SWEEP_INTERVAL_MS }, + ...deps, + ...overrides + }) + const rig: RestTestRig = { + root, + store, + host: hostFor({}), + adapter, + clock, + statusEvents, + sink, + restart: async (overrides = {}) => { + await rig.host.flushAllStreamedEvents().catch(() => undefined) + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + rig.store = store + rig.host = hostFor(overrides) + rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) + return rig.host + }, + dispose: async () => { + await rig.host.flushAllStreamedEvents().catch(() => undefined) + await rm(root, { recursive: true, force: true }) + } + } + rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) + return rig +} + +/** Creates the chat, lists its tab, and sends one message so its journal is on disk. */ +export async function foundRestTestChat(rig: RestTestRig): Promise { + const attached = await rig.host.attach(REST_TEST_CALLER, hostTestAttachParams(null)) + if (!attached.ok) { + throw new Error(`attach refused: ${attached.refusal.code}`) + } + await rig.store.setSessionTabVisibility(SESSION, true) + const sent = await rig.host.send(REST_TEST_CALLER, restTestSend('hello', attached.fence)) + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalled()) +} + +/** Runs one sweep pass now, for a test that set `idleSweep.intervalMs` out of reach. */ +export function sweepOnce(host: StructuredAgentSessionHost): Promise { + return host.collaboratorsForTests().lifetime.idleSweep.tick() +} + +/** Waits long enough for several sweep ticks to have run. */ +export function sweepTicks(count = 6): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_INTERVAL_MS * count)) +} + +export { SESSION as REST_TEST_SESSION, THREAD as REST_TEST_THREAD } 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 46879aefe49..50008ce79c4 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 @@ -2,16 +2,11 @@ // // A different question from storage: the durable record decides which markers are still present; // this decides which of those a resume may act on. The offer, the click and the pre-send check all -// ask it, and all get the same answer: the marker stands until the user sends a newer message. +// ask it, and all get the same answer. -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { - latestStructuredAgentSessionPrompt, - latestStructuredAgentSessionUserItem -} from '../../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-projection' 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' @@ -23,61 +18,29 @@ import { /** The only part of a live session this reads. */ export type StructuredAgentSessionRestartJournalSource = { journal: AgentSessionJournal } -export type StructuredAgentSessionRestartCandidateOptions = { - /** A continuation already in flight; its own submission is not newer user work. */ - pendingContinuationId?: string -} - export type StructuredAgentSessionRestartCandidateReader = ( markers: readonly AgentSessionResumeMarker[], - leaseState: 'must-be-released' | 'may-be-held', - options?: StructuredAgentSessionRestartCandidateOptions + leaseState: 'must-be-released' | 'may-be-held' ) => StructuredAgentSessionResumableSet -/** The newest user message in a live session's journal, the same fact the predicate compares - * against a marker. Undefined when the session is not readable here, which decides nothing. */ -export function liveStructuredAgentSessionLatestUserItemId( - sessions: ReadonlyMap, - sessionId: string -): string | null | undefined { - const session = sessions.get(sessionId) - return session - ? (latestStructuredAgentSessionUserItem(session.journal.snapshot().items)?.itemId ?? null) - : undefined -} - export function createStructuredAgentSessionRestartCandidateReader(deps: { /** The host's live session map; a marker's chat is readable once listing has revealed it. */ sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter + /** Whether the chat moved on since the offer was taken; see the offer withdrawal. */ + movedOn: (marker: AgentSessionResumeMarker) => boolean }): StructuredAgentSessionRestartCandidateReader { - return (markers, leaseState, options = {}) => { - const items = new Map() - const itemsFor = (sessionId: string): AgentJournalRenderItem[] | undefined => { - if (items.has(sessionId)) { - return items.get(sessionId) - } - let snapshot = deps.sessions.get(sessionId)?.journal.snapshot().items - if (snapshot && options.pendingContinuationId) { - const ownItemId = agentJournalSubmissionKey(options.pendingContinuationId) - snapshot = snapshot.filter((item) => item.itemId !== ownItemId) - } - items.set(sessionId, snapshot) - return snapshot - } - return structuredAgentSessionResumableSet({ + return (markers, leaseState) => + structuredAgentSessionResumableSet({ markers, getRecord: deps.getRecord, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - latestPrompt: (sessionId) => latestStructuredAgentSessionPrompt(itemsFor(sessionId) ?? []), - latestUserItemId: (sessionId) => { - const snapshot = itemsFor(sessionId) - return snapshot === undefined - ? undefined - : (latestStructuredAgentSessionUserItem(snapshot)?.itemId ?? null) - }, + movedOn: deps.movedOn, + latestPrompt: (sessionId) => + latestStructuredAgentSessionPrompt( + deps.sessions.get(sessionId)?.journal.snapshot().items ?? [] + ), leaseState }) - } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts new file mode 100644 index 00000000000..9ded5b54d98 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts @@ -0,0 +1,86 @@ +// "Resume all" starts agents a few at a time. A continuation is a send, so a chat counts as started +// once its agent took the message or its start failed — not when the message was accepted, which +// is instant and would let every agent start at once. + +import { expect, it, vi } from 'vitest' +import { + startStructuredAgentSessionContinuation, + type StructuredAgentSessionContinuationDeps +} from './structured-agent-session-restart-continuation' +import { + resumeStructuredAgentSessionsFromRestart, + STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY, + StructuredAgentSessionResumeAdmission +} from './structured-agent-session-restart-resume-runner' +import { marker } from './structured-agent-session-restart-resume-test-harness' + +type Handover = { dispatchState: string; reason?: string | null } | undefined + +it('holds each slot from accept until handover or rejection, and frees it when the wait ends (P2-27)', async () => { + const handovers = new Map>() + let waiting = 0 + let mostWaiting = 0 + const deps = (sessionId: string): StructuredAgentSessionContinuationDeps => ({ + currentFence: () => 1, + send: vi.fn(async () => ({ ok: true, value: { submission: { dispatchState: 'pending' } } })), + awaitHandedOver: async () => { + const handover = Promise.withResolvers() + handovers.set(sessionId, handover) + waiting += 1 + mostWaiting = Math.max(mostWaiting, waiting) + try { + return await handover.promise + } finally { + waiting -= 1 + } + }, + awaitSettlement: async () => ({ dispatchState: 'accepted' }), + note: async () => undefined, + onNoteFailed: () => undefined + }) + const sessions = Array.from({ length: 8 }, (_, index) => `session-${index}`) + const outcomes = resumeStructuredAgentSessionsFromRestart( + { + admission: new StructuredAgentSessionResumeAdmission(), + consumeMarker: async () => true, + resume: async (sessionId) => { + const started = await startStructuredAgentSessionContinuation( + deps(sessionId), + sessionId, + marker(), + 'operation-1' + ) + if ('done' in started && started.done.outcome === 'refused') { + throw new Error(started.done.reason ?? 'refused') + } + } + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runner reads only each candidate's session id. + sessions.map((sessionId) => ({ sessionId }) as never), + 'modal' + ) + const settle = async (sessionId: string, handover: Handover) => { + await vi.waitFor(() => expect(handovers.has(sessionId)).toBe(true)) + handovers.get(sessionId)!.resolve(handover) + } + + await vi.waitFor(() => expect(waiting).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY)) + // Accepted but not yet handed over: a slow start keeps its slot. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(handovers.size).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + await settle('session-0', { dispatchState: 'pending' }) + await settle('session-1', { dispatchState: 'rejected', reason: 'Codex could not start.' }) + // The session closed, or too many waited: the wait proves nothing, and the slot is freed. + await settle('session-2', undefined) + for (const sessionId of sessions.slice(3)) { + await settle(sessionId, { dispatchState: 'pending' }) + } + + const results = await outcomes + expect(mostWaiting).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + expect(results.find((result) => result.sessionId === 'session-1')).toMatchObject({ + outcome: 'refused', + reason: 'Codex could not start.' + }) + expect(results.filter((result) => result.outcome === 'resumed')).toHaveLength(7) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts index 63c778144ee..6d8514c830e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts @@ -2,10 +2,7 @@ import { expect, it, vi } from 'vitest' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' -import { - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD -} from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' // What the user hit: the lead had finished, its subagent had not. The offer names the subagent // from the roster snapshot taken BEFORE the child stopped — the close that follows settles the @@ -21,65 +18,22 @@ it('offers a chat whose subagent the restart stopped, named from the stop-time s ]) }) -// On resume the provider restates what it lost in its own words, rewriting the row before the -// continuation is dispatched. What the offer was admitted for still stands. -it('continues a stopped subagent after the provider restates its row', async () => { - const { host, acquire, dispatch } = await interruptedRestart('children') - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - const admit = vi.spyOn(host.deps.store, 'admitMutationOperation') - admit.mockImplementationOnce(async (input) => { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 2 }, - { - kind: 'message', - role: 'system', - blocks: [ - { - type: 'subagent-group', - groupId: 'settled-turn', - agents: [{ id: 'child-1', label: 'Review loop 4', state: 'completed' }] - } - ] - } - ) - await host.flushStreamedEvents(SESSION) - admit.mockRestore() - return host.deps.store.admitMutationOperation(input) - }) +// The offer was admitted for the subagent's work; the continuation asks the agent to carry it on. +it('continues a chat whose subagent the restart stopped', async () => { + const { host, dispatch } = await interruptedRestart('children') expect( (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued ).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') }) -// The user opens the chat before choosing Resume, and the reattached provider restates its row. +// The user opens the chat before choosing Resume: reading it starts nothing, so the offer stands. it('still offers and continues a chat the user opened before resuming', async () => { const { host, acquire, dispatch } = await interruptedRestart('children') - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 2 }, - { - kind: 'message', - role: 'system', - blocks: [ - { - type: 'subagent-group', - groupId: 'settled-turn', - agents: [{ id: 'child-1', label: 'Review loop 4', state: 'completed' }] - } - ] - } - ) - await host.flushStreamedEvents(SESSION) + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + unsubscribe() + expect(acquire).not.toHaveBeenCalled() expect(await host.restartResume.list()).toMatchObject([ { sessionId: SESSION, activity: { tasks: [{ kind: 'agent', label: 'Review loop 4' }] } } ]) @@ -87,5 +41,4 @@ it('still offers and continues a chat the user opened before resuming', async () (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued ).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts new file mode 100644 index 00000000000..cfa2c7b502e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts @@ -0,0 +1,77 @@ +// The restart continuation as a message: its body, which depends on the marker alone, and its +// identity, which is one per resume action. + +import { createHash } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { restartContinuationMessage } from '../../../shared/agent-session-restart-continuation' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' + +/** The message body, built once so both the send and any test read the same text. */ +export function restartContinuationBody(marker: AgentSessionResumeMarker): AgentJournalMessageItem { + return { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: restartContinuationMessage(marker) }] + } +} + +const hex16 = (parts: readonly unknown[]): string => + createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16) + +/** Names the offer: every continuation any of its actions sends carries it, and nothing else does. */ +function restartOfferTag(marker: AgentSessionResumeMarker): string { + return hex16([marker.teardownId, marker.sessionId]) +} + +/** One resume action's continuation: the offer's tag, then the action's own part, so a retry is a + * new message, never a replay of the one that failed. Dated by the action, not the quit: the + * ledger refuses a new id dated more than a day back, and an offer has no expiry. */ +export function restartContinuationId( + marker: AgentSessionResumeMarker, + operationId: string, + actionAt: number +): string { + return `${Math.trunc(actionAt).toString().padStart(13, '0')}-${restartOfferTag(marker)}${hex16([ + marker.teardownId, + marker.sessionId, + marker.work.kind, + marker.work.id, + marker.providerHandleRoot, + operationId + ])}` +} + +/** Whether a message is a continuation one of this offer's resume actions sent, read off its id. */ +export function isRestartContinuationOf( + marker: AgentSessionResumeMarker, + clientMessageId: string +): boolean { + return /^\d{13}-[0-9a-f]{32}$/.test(clientMessageId) + ? clientMessageId.slice(14, 30) === restartOfferTag(marker) + : false +} + +/** The fence only fills the envelope: admission names this send by its operation id, not a fence. */ +export function restartContinuationEnvelope( + sessionId: string, + fence: number, + marker: AgentSessionResumeMarker, + continuationId: string +): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { + const body = restartContinuationBody(marker) + return { + body, + envelope: { + sessionId, + clientOperationId: continuationId, + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) + } + } +} 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 8ab9bb250ad..cfbdd9ce35e 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 @@ -41,8 +41,6 @@ describe('the restart continuation waits for its delivery (W18)', () => { const surfaces = structuredAgentSessionRestartResumeSurfaces( { revealSession: async () => ({ readable: true }), - hold: async () => undefined, - release: () => undefined, send: async () => { throw new Error('not used') }, @@ -61,3 +59,54 @@ describe('the restart continuation waits for its delivery (W18)', () => { }) }) }) + +describe('a restart batch holds a chat until its continuation is handed over', () => { + function surfacesOver(settlement: StructuredAgentSessionSendSettlement) { + return structuredAgentSessionRestartResumeSurfaces( + { + revealSession: async () => ({ readable: true }), + send: async () => { + throw new Error('not used') + }, + waitForSendSettlement: settlement.wait + }, + () => 0 + ) + } + + it('keeps waiting through a slow start and resolves once the agent takes the message', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + let released = false + const handedOver = surfacesOver(settlement) + .awaitSendHandedOver(SESSION, MESSAGE) + .then((result) => { + released = true + return result + }) + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(released).toBe(false) + // Handed over: still pending at the provider, no longer queued. + submission = { ...submission, handedOverAt: 2 } + settlement.publish(SESSION, journal) + await expect(handedOver).resolves.toMatchObject({ + value: { submission: { dispatchState: 'pending', handedOverAt: 2 } } + }) + }) + + it('resolves once the start fails and the message is rejected', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + const handedOver = surfacesOver(settlement).awaitSendHandedOver(SESSION, MESSAGE) + submission = { ...submission, dispatchState: 'rejected', reason: 'Codex could not start.' } + settlement.publish(SESSION, journal) + await expect(handedOver).resolves.toMatchObject({ + value: { submission: { dispatchState: 'rejected' } } + }) + }) + + it('resolves undefined when the session closes first', async () => { + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + const handedOver = surfacesOver(settlement).awaitSendHandedOver(SESSION, MESSAGE) + settlement.closeSession(SESSION) + await expect(handedOver).resolves.toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts index 2d558f8f028..0fe36994cd7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts @@ -6,13 +6,22 @@ import { AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE } from '../../../shared/agent-session-restart-continuation' import { - continueStructuredAgentSessionAfterRestart, RestartContinuationSupersededError, + startStructuredAgentSessionContinuation, type StructuredAgentSessionContinuationDeps } from './structured-agent-session-restart-continuation' +/** The whole continuation: handed over, then its verdict. */ +async function continueStructuredAgentSessionAfterRestart( + ...args: Parameters +) { + const started = await startStructuredAgentSessionContinuation(...args) + return 'done' in started ? started.done : started.verdict() +} + function dependencies( - settledDispatch: 'accepted' | 'pending' | 'unknown' | 'rejected' + settledDispatch: 'accepted' | 'pending' | 'unknown' | 'rejected', + handedOver: 'pending' | 'rejected' | undefined = 'pending' ): StructuredAgentSessionContinuationDeps & { note: ReturnType send: ReturnType @@ -27,6 +36,14 @@ function dependencies( dispatchState: settledDispatch, reason: settledDispatch === 'rejected' ? 'provider_refused' : null })), + awaitHandedOver: vi.fn(async () => + handedOver + ? { + dispatchState: handedOver, + reason: handedOver === 'rejected' ? 'Codex could not start.' : null + } + : undefined + ), note: vi.fn(async () => undefined), onNoteFailed: vi.fn() } @@ -36,7 +53,7 @@ it('reports an accepted continuation and records its note', async () => { const deps = dependencies('accepted') await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'continued' @@ -58,20 +75,20 @@ it.each([ const deps = dependencies(settled) await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual(expected) expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note) } ) -it('notes a superseded continuation in the chat and still reports the refusal', async () => { +it("writes nothing in the chat when the user's own message came first, and still refuses", async () => { const deps = dependencies('accepted') deps.send.mockRejectedValue(new RestartContinuationSupersededError()) await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).rejects.toBeInstanceOf(RestartContinuationSupersededError) - expect(deps.note).toHaveBeenCalledExactlyOnceWith(...REFUSED) + expect(deps.note).not.toHaveBeenCalled() }) // An ownership refusal would meet the user's own message too, so the note gives no advice to send one. @@ -83,7 +100,7 @@ it.each([ deps.send.mockResolvedValue({ ok: false, refusal: { code } }) await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', @@ -98,7 +115,7 @@ it('reports an unattached chat without sending', async () => { deps.currentFence = () => null await expect( - continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', @@ -106,3 +123,45 @@ it('reports an unattached chat without sending', async () => { }) expect(deps.send).not.toHaveBeenCalled() }) + +// A start that failed rejects the continuation before any provider saw it: that is the verdict. +it('reports a continuation rejected at handover without waiting for the provider', async () => { + const deps = dependencies('accepted', 'rejected') + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') + ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', reason: 'Codex could not start.' }) + expect(deps.awaitSettlement).not.toHaveBeenCalled() + expect(deps.note).toHaveBeenCalledExactlyOnceWith(...REFUSED) +}) + +// The start completes once the agent took the message; the provider's answer is a later verdict. +it('returns at handover and leaves the provider verdict to be awaited', async () => { + const deps = dependencies('accepted') + + const started = await startStructuredAgentSessionContinuation( + deps, + SESSION, + marker(), + 'operation-1' + ) + + expect(deps.awaitHandedOver).toHaveBeenCalledOnce() + expect(deps.awaitSettlement).not.toHaveBeenCalled() + if (!('verdict' in started)) { + throw new Error('expected a handed-over continuation') + } + await expect(started.verdict()).resolves.toEqual({ sessionId: SESSION, outcome: 'continued' }) + expect(deps.note).toHaveBeenCalledOnce() +}) + +// A handover wait that ends without an answer — the session closed, or too many waited — proves +// nothing, so the provider's verdict still decides. +it('still awaits the verdict when the handover wait ends unanswered', async () => { + const deps = dependencies('unknown', undefined) + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') + ).resolves.toEqual({ sessionId: SESSION, outcome: 'unknown' }) + expect(deps.note).toHaveBeenCalledExactlyOnceWith(...UNCONFIRMED) +}) 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 87a56efb7b9..9dd04929385 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 @@ -1,27 +1,31 @@ // Asking an interrupted agent to carry on, on the user's opt-in. // -// Reattaching and continuing are SEPARATE operations: `resume` reattaches and sends nothing; this -// adds one message on top of it. Both the restart prompt and an opted-in launch come here, so a -// SETTING can reach this send — acceptable because the work is the user's own, the message asks the -// agent to verify its last action before repeating it, and the launch toast reports what happened. +// The continuation is a send like any other: accepted into the conversation, and delivered by the +// session's delivery loop, which starts the agent. Both the restart prompt and an opted-in launch +// come here, so a SETTING can reach this send — acceptable because the work is the user's own, the +// 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 { + readAgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, - restartContinuationMessage + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE } from '../../../shared/agent-session-restart-continuation' import { AgentSessionPreDispatchError } from './structured-agent-session-operation-settlement' -import { createHash } from 'node:crypto' +import { restartContinuationEnvelope } from './structured-agent-session-restart-continuation-envelope' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' /** @@ -40,12 +44,15 @@ export type StructuredAgentSessionContinuationOutcome = { sessionId: string outcome: 'continued' | 'pending' | 'unknown' | 'refused' reason?: string + /** The refusal that kept the agent from starting; `reason` is then its code. */ + refusal?: AgentSessionRefusalReference } /** The slice of the host one continuation needs. Structural so this module never imports the host. */ export type StructuredAgentSessionContinuationHost = { sessions: ReadonlyMap - /** The fence a conversation write carries; null when the session is not open. */ + /** The fence a conversation write carries; null when this host has no record of the session. + * The send opens the conversation itself, so a closed one still answers. */ conversationFence: (sessionId: string) => number | null send: (input: { envelope: AgentSessionMutationEnvelope @@ -56,15 +63,15 @@ export type StructuredAgentSessionContinuationHost = { sessionId: string, clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> + awaitSendHandedOver: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> onNoteFailed: (sessionId: string, error: unknown) => void now: () => number - /** Whether the marker is still an offer, with the continuation's own submission set aside. - * Re-asked right before dispatch, so a newer user message refuses the send; a provider turn - * running then does not, since both providers queue a message sent mid-turn. */ - stillResumable: ( - marker: AgentSessionResumeMarker, - options: { pendingContinuationId: string } - ) => boolean + /** Whether the marker is still an offer. Asked at acceptance, inside the session lock, so the + * first message accepted since the restart decides: the user's, or this continuation. */ + stillResumable: (marker: AgentSessionResumeMarker) => boolean } /** Binds one continuation to the host: the superseded check before dispatch, the settlement @@ -79,34 +86,20 @@ export function restartContinuationDeps( host.send({ ...input, beforeRun: () => { - if ( - !host.stillResumable(marker, { - pendingContinuationId: input.envelope.clientOperationId - }) - ) { + if (!host.stillResumable(marker)) { throw new RestartContinuationSupersededError() } } }), awaitSettlement: async (sessionId, clientMessageId) => (await host.awaitSendSettlement(sessionId, clientMessageId))?.value.submission, + awaitHandedOver: async (sessionId, clientMessageId) => + (await host.awaitSendHandedOver(sessionId, clientMessageId))?.value.submission, onNoteFailed: host.onNoteFailed, note: restartNoteWriter(host) } } -/** The note for a reattach that failed before any continuation was attempted. */ -export function noteRestartReattachFailed( - host: StructuredAgentSessionContinuationHost, - sessionId: string -): Promise { - return noteNotContinued( - { note: restartNoteWriter(host), onNoteFailed: host.onNoteFailed }, - sessionId, - 'not-connected' - ) -} - /** Writes a host-authored status note into the chat. */ function restartNoteWriter( host: Pick @@ -132,59 +125,25 @@ const OWNERSHIP_REFUSALS = new Set([ 'execution_owner_reconciling' ]) +/** The user's own message was accepted first; the offer is spent, and nothing failed. */ +export const RESTART_CONTINUATION_SUPERSEDED = 'agent_session_restart_work_superseded' + /** Only this pre-dispatch failure proves a thrown send did not deliver. */ export class RestartContinuationSupersededError extends AgentSessionPreDispatchError { constructor() { - super('agent_session_restart_work_superseded') + super(RESTART_CONTINUATION_SUPERSEDED) this.name = 'RestartContinuationSupersededError' } } -/** The message body, built once so both the send and any test read the same text. */ -export function restartContinuationBody(marker: AgentSessionResumeMarker): AgentJournalMessageItem { - return { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: restartContinuationMessage(marker) }] - } -} - -/** The fence only fills the envelope: admission names this send by its operation id, not a fence. */ -export function restartContinuationEnvelope( - sessionId: string, - fence: number, - marker: AgentSessionResumeMarker -): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { - const body = restartContinuationBody(marker) - return { - body, - envelope: { - sessionId, - // The same interrupted work must reach the durable ledger with the same message identity. - clientOperationId: `${marker.recordedAt.toString().padStart(13, '0')}-${createHash('sha256') - .update( - JSON.stringify([ - marker.teardownId, - sessionId, - marker.work.kind, - marker.work.id, - marker.providerHandleRoot - ]) - ) - .digest('hex') - .slice(0, 32)}`, - expectedRuntimeFence: fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) - } - } +type ContinuationSubmission = { + dispatchState?: string + reason?: string | null + rejection?: UnreadAgentSessionFailureFact } export type StructuredAgentSessionContinuationDeps = { - /** Runtime fence as it stands now; null when the session is not attached. */ + /** Runtime fence as it stands now; null when this host has no record of the session. */ currentFence: (sessionId: string) => number | null send: (input: { envelope: AgentSessionMutationEnvelope @@ -205,7 +164,13 @@ export type StructuredAgentSessionContinuationDeps = { awaitSettlement: ( sessionId: string, clientMessageId: string - ) => Promise<{ dispatchState?: string; reason?: string | null } | undefined> + ) => Promise + /** Waits until the send is handed to a started agent or rejected. Undefined when the wait ended + * first — the session closed, or too many waited — which proves neither. */ + awaitHandedOver: ( + sessionId: string, + clientMessageId: string + ) => Promise /** Records a host-authored journal note: that this send was Orca's, not the user's, or that the * chat did not carry on. `tone` is a display hint older clients render as plain text. */ note: (sessionId: string, text: string, tone?: 'error' | 'warning') => Promise @@ -214,26 +179,61 @@ export type StructuredAgentSessionContinuationDeps = { onNoteFailed: (sessionId: string, error: unknown) => void } +/** A continuation handed to its agent, or already decided. */ +export type StartedStructuredAgentSessionContinuation = + | { done: StructuredAgentSessionContinuationOutcome } + | { verdict: () => Promise } + /** - * Sends the continuation to ONE already-reconnected session. - * - * The caller must have reconnected it first: this deliberately does not reconnect, so that every - * eligibility check, the admission gate and the consume-once ordering stay in the resume path and - * are not re-implemented here. + * Sends the continuation to ONE session and returns once the agent has taken it or its start + * failed — the point a restart batch counts a start as done. What the provider then answered is + * the `verdict`, awaited separately so a slow answer does not hold the batch. */ -export async function continueStructuredAgentSessionAfterRestart( +export async function startStructuredAgentSessionContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker -): Promise { - let result: StructuredAgentSessionContinuationOutcome + marker: AgentSessionResumeMarker, + /** This action's continuation, as its offer recorded it. */ + continuationId: string +): Promise { + let started: StartedStructuredAgentSessionContinuation try { - result = await sendContinuation(deps, sessionId, marker) + started = await sendContinuation(deps, sessionId, marker, continuationId) } catch (error) { - await noteNotContinued(deps, sessionId, 'refused') + // The user's own message came first: nothing failed, so the chat says nothing. + if (!(error instanceof RestartContinuationSupersededError)) { + await noteNotContinued(deps, sessionId, 'refused') + } throw error } - if (result.outcome !== 'continued') { + if ('done' in started) { + await noteOutcome(deps, sessionId, started.done) + return started + } + const { verdict } = started + return { + verdict: async () => { + const outcome = await verdict() + await noteOutcome(deps, sessionId, outcome) + return outcome + } + } +} + +async function noteOutcome( + deps: Pick, + sessionId: string, + result: StructuredAgentSessionContinuationOutcome +): Promise { + if (result.outcome === 'continued') { + // Only an accepted dispatch gets the note: it is a durable claim that Orca asked this agent to + // carry on. Best effort — losing it must not turn a delivered continuation into a failure. + try { + await deps.note(sessionId, AGENT_SESSION_RESTART_CONTINUATION_NOTE) + } catch (error) { + deps.onNoteFailed(sessionId, error) + } + } else { await noteNotContinued( deps, sessionId, @@ -244,7 +244,6 @@ export async function continueStructuredAgentSessionAfterRestart( : 'refused' ) } - return result } /** The chat itself carries the failure, so it survives the toast, a dismissed record and a restart, @@ -272,13 +271,14 @@ async function noteNotContinued( async function sendContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker -): Promise { + marker: AgentSessionResumeMarker, + continuationId: string +): Promise { const fence = deps.currentFence(sessionId) if (fence === null) { - return { sessionId, outcome: 'refused', reason: 'agent_session_not_attached' } + return { done: { sessionId, outcome: 'refused', reason: 'agent_session_not_attached' } } } - const { envelope, body } = restartContinuationEnvelope(sessionId, fence, marker) + const { envelope, body } = restartContinuationEnvelope(sessionId, fence, marker, continuationId) const sent = await deps.send({ envelope, body }).catch((error: unknown) => { if (error instanceof AgentSessionPreDispatchError) { throw error @@ -288,46 +288,64 @@ async function sendContinuation( return null }) if (!sent) { - return { sessionId, outcome: 'unknown' } + return { done: { sessionId, outcome: 'unknown' } } } if (!sent.ok) { return { - sessionId, - outcome: 'refused', - reason: sent.refusal?.code ?? 'agent_session_send_failed' + done: { + sessionId, + outcome: 'refused', + reason: sent.refusal?.code ?? 'agent_session_send_failed' + } } } - // The send result carries the dispatch as it stood when Orca took the message, which for a normal - // successful send is `pending`. Judging it here would report every delivered continuation as - // pending and never write the note, so the settled value is what decides. - const submission = - (await deps.awaitSettlement(sessionId, envelope.clientOperationId).catch(() => undefined)) ?? - sent.value?.submission + const clientMessageId = envelope.clientOperationId + const handedOver = await deps.awaitHandedOver(sessionId, clientMessageId).catch(() => undefined) + if (handedOver?.dispatchState === 'rejected') { + return { done: refusedBy(sessionId, handedOver) } + } + return { + verdict: async () => + verdictOf( + sessionId, + // The send result carries the dispatch as it stood when Orca took the message, which for + // a normal successful send is `pending`, so the settled value is what decides. + (await deps.awaitSettlement(sessionId, clientMessageId).catch(() => undefined)) ?? + handedOver ?? + sent.value?.submission + ) + } +} + +function refusedBy( + sessionId: string, + submission: ContinuationSubmission +): StructuredAgentSessionContinuationOutcome { + // A start the agent was refused files that refusal's code, which the failure guidance keys on. + const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal + return { + sessionId, + outcome: 'refused', + reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected', + ...(refusal ? { refusal } : {}) + } +} + +function verdictOf( + sessionId: string, + submission: ContinuationSubmission | undefined +): StructuredAgentSessionContinuationOutcome { const dispatch = submission?.dispatchState - if (dispatch === 'rejected') { - return { - sessionId, - outcome: 'refused', - reason: submission?.reason ?? 'agent_session_dispatch_rejected' - } + if (submission && dispatch === 'rejected') { + return refusedBy(sessionId, submission) } if (dispatch === 'pending') { // Still pending after settlement gave up: handed off, never confirmed. return { sessionId, outcome: 'pending' } } - if (dispatch !== 'accepted') { - // `unknown`, or a peer that reported no state at all. Delivery is unverifiable, so this claims - // neither success nor failure — and writes no note saying the agent was asked to continue. - return { sessionId, outcome: 'unknown' } - } - // Only an accepted dispatch gets the note: it is a durable claim that Orca asked this agent to - // carry on, and it must not sit beside a message the provider refused or never confirmed. Best - // effort beyond that — losing the note must not turn a delivered continuation into a failure — - // but reported, never swallowed. - try { - await deps.note(sessionId, AGENT_SESSION_RESTART_CONTINUATION_NOTE) - } catch (error) { - deps.onNoteFailed(sessionId, error) - } - return { sessionId, outcome: 'continued' } + // `unknown`, or a peer that reported no state at all: delivery is unverifiable, so this claims + // neither success nor failure — and writes no note saying the agent was asked to continue. + return dispatch === 'accepted' + ? { sessionId, outcome: 'continued' } + : { sessionId, outcome: 'unknown' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index 97830212b0a..f1fe0a40b42 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -2,20 +2,18 @@ import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' +import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { interruptedRestart, statusNotes } from './structured-agent-session-restart-interruption-test-harness' +import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' @@ -23,206 +21,71 @@ import { afterEach(() => vi.restoreAllMocks()) -function providerEvents(acquire: Awaited>['acquire']) { - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - return events -} - -// The reported case: after the reattach the provider replays a queued message, which the chat -// journals as a newer user turn, so the continuation is refused just before dispatch. -it('files a continuation superseded by a replayed message, lists it and says so in the chat', async () => { - const { host, acquire, dispatch, root } = await interruptedRestart() - await host.restartResume.list() - await host.hold(SESSION, 'pane') - const events = providerEvents(acquire) - // The replay lands after the reattach and just before the continuation is accepted, which is - // where a send asks whether its offer still stands. - const send = host.send - vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'replayed-turn', ordinal: 1 }, - hostTestMessage('A queued notification the provider replayed') - ) - await host.flushStreamedEvents(SESSION) - return send(caller, params) - }) - - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - - expect(dispatch).not.toHaveBeenCalled() - expect(result.resumed).toMatchObject([ - { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ]) - const failure = { - sessionId: SESSION, - outcome: 'refused', - reason: 'agent_session_restart_work_superseded' - } - expect(result.failed).toMatchObject([failure]) - expect(await host.restartResume.listFailures()).toMatchObject([failure]) - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toHaveLength(1) - expect(statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - tone: 'error' - }) - host.release(SESSION, 'pane') -}) - -// Nothing was attempted and nothing is owed: the user moved on between listing and acting. +// Nothing was owed once the user's own message came first: the offer is spent and nothing is filed. it('files nothing for a chat the user moved on in before its attempt, and spends the offer', async () => { - const { host, acquire, root } = await interruptedRestart() + const { host, root, dispatch } = await interruptedRestart() await host.restartResume.list() - await host.hold(SESSION, 'pane') - const events = providerEvents(acquire) const admit = StructuredAgentSessionResumeAdmission.prototype.run vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run').mockImplementationOnce( async function (this, ...args) { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'newer-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client') - ) - await host.flushStreamedEvents(SESSION) + const body = hostTestMessage('A newer task from another client') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) return admit.apply(this, args) } ) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.resumed).toMatchObject([{ reason: 'agent_session_resume_not_eligible' }]) + expect(result.continued.filter((entry) => entry.outcome === 'continued')).toEqual([]) expect(result.failed).toEqual([]) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) - expect(statusNotes(host)).toEqual([]) - host.release(SESSION, 'pane') + expect(await statusNotes(host)).toEqual([]) }) -it.each(['resume', 'continueAfterRestart'] as const)( - 'says so in the chat when the reattach itself fails (%s)', - async (action) => { - const { host, acquire } = await interruptedRestart() - await host.restartResume.list() - acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) +// The continuation is accepted and its agent then fails to start: the message is rejected with the +// cause, the failure is filed, and the chat says the agent did not carry on. +it('says so in the chat when the agent cannot start for the continuation', async () => { + const { host, acquire } = await interruptedRestart() + await host.restartResume.list() + acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) - await host.restartResume[action]([SESSION], 'modal') + await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(await host.restartResume.listFailures()).toMatchObject([ - { sessionId: SESSION, outcome: 'refused' } - ]) - // The fix depends on why it failed, which the dialog explains; "send a message" would not work. - expect(statusNotes(host)).toEqual([ - { text: AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, tone: 'error' } - ]) - } -) + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, outcome: 'refused', retryable: true } + ]) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) +}) -/** A continuation the provider accepted whose settlement could not be written: filed unconfirmed. */ -async function unconfirmedContinuation() { - const state = await interruptedRestart() - const { host, store } = state +// A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, +// and it stays on record while the agent that may be carrying on keeps running. +it('keeps an unconfirmed failure while the agent the continuation started keeps running', async () => { + const { host, store } = await interruptedRestart() vi.spyOn(console, 'warn').mockImplementation(() => {}) await host.restartResume.list() - await host.hold(SESSION, 'pane') const settle = store.recordOperationOutcome.bind(store) vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded') { + if ( + input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && + input.outcome.status === 'succeeded' + ) { throw new Error('operation outcome could not be persisted') } return settle(input) }) + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - expect(statusNotes(host)).toContainEqual({ + expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, tone: 'warning' }) - const continuation = host.journalSnapshot(SESSION).submissions.at(-1) - const providerItemId = continuation?.providerItemId - if (!continuation || !providerItemId) { - throw new Error('missing accepted continuation') - } - return { ...state, continuation, providerItemId, events: providerEvents(state.acquire) } -} - -// The warning asked the user to check the agent's reply; a turn opened by the continuation's own -// message is that reply starting, however the provider names the message. -it.each(['submission key', 'provider key'] as const)( - 'retires an unconfirmed failure once the continuation opens a turn (%s)', - async (naming) => { - const { host, root, continuation, providerItemId, events } = await unconfirmedContinuation() - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'continued-turn', ordinal: 1 }, - { - kind: 'turn', - turnId: 'continued-turn', - state: 'running', - userItemId: - naming === 'submission key' - ? agentJournalSubmissionKey(continuation.clientMessageId) - : providerItemId - }, - { lifecycle: true } - ) - await host.flushStreamedEvents(SESSION) - - expect(await host.restartResume.listFailures()).toEqual([]) - await vi.waitFor(async () => { - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) - }) - host.release(SESSION, 'pane') - } -) - -it('keeps an unconfirmed failure while the newest turn is not the continuation’s', async () => { - const { host, events } = await unconfirmedContinuation() - // Provider output opened this turn, keyed by its own row rather than by any user message. - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'provider-turn', state: 'running', userItemId: 'provider-turn-row' }, - { lifecycle: true } - ) - await host.flushStreamedEvents(SESSION) - expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) - host.release(SESSION, 'pane') -}) - -// Nothing journaled the continuation, so the newest user message is still the interrupted one and -// the turn it opened is the interrupted work reporting in, not the agent carrying on. -it('keeps an unconfirmed failure whose continuation was never journaled while the interrupted turn runs', async () => { - const { host, acquire, store, marker } = await interruptedRestart('submission', false) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - await host.restartResume.list() - await host.hold(SESSION, 'pane') - const events = providerEvents(acquire) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - hostTestMessage('Perform the original task') - ) - await host.flushStreamedEvents(SESSION) - // A send that throws before recording anything cannot be proven undelivered. - vi.spyOn(store, 'admitMutationOperation').mockRejectedValueOnce(new Error('store unavailable')) - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - const submissions = host.journalSnapshot(SESSION).submissions - const original = submissions[0]?.providerItemId - if (submissions.length !== 1 || !original) { - throw new Error('expected only the original submission, accepted by the provider') - } - expect(latestStructuredAgentSessionUserItem(host.journalSnapshot(SESSION).items)?.itemId).toBe( - marker?.latestUserItemId - ) - - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 2 }, - { kind: 'turn', turnId: 'original-turn', state: 'running', userItemId: original }, - { lifecycle: true } - ) - await host.flushStreamedEvents(SESSION) - - expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) - host.release(SESSION, 'pane') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 3b8fad40de0..ee769b9e8c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -4,9 +4,8 @@ // the offer, so without this record nothing durable would point at the chat the user has to // continue by hand. The capsule holds the record; this decides what goes in and when it leaves. // -// Whether a record is still current is derived, never cached: it is current only while the chat's -// newest user message is the one it had when the failure was filed. The user's own send, from any -// client, therefore retires it without a hook on the send path. +// A record ends when the chat's agent is started again outside a resume action — the host retires +// it at that start, with the offer — or by a successful retry, or a dismissal. import type { AgentSessionRecoveryCapsule, @@ -18,19 +17,10 @@ import type { AgentSessionResumeFailureOutcome, AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { adapterSupportsRecord } from './structured-agent-session-provider-support' -import { - liveStructuredAgentSessionLatestUserItemId, - type StructuredAgentSessionRestartJournalSource -} from './structured-agent-session-restart-candidates' import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' import type { StructuredAgentSessionResumeCandidate, @@ -40,37 +30,18 @@ import { STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE, type StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' +import { RESTART_CONTINUATION_SUPERSEDED } from './structured-agent-session-restart-continuation' type FailureCapsule = Pick< AgentSessionRecoveryCapsule, - | 'listFailed' - | 'completeResume' - | 'failResume' - | 'rollbackResume' - | 'dismiss' - | 'clearAll' - | 'forgetSuperseded' + 'listFailed' | 'completeResume' | 'failResume' | 'rollbackResume' | 'dismiss' | 'clearAll' > -/** What a failure is filed against: the chat's newest user message as its own attempt ended. Kept - * per chat rather than read at settlement, because the rest of a batch can take a while and a - * message the user sends meanwhile answers the failure rather than belongs to it. */ -export type StructuredAgentSessionRestartAttempts = { - observe: (sessionId: string) => void - latestUserItemId: (sessionId: string) => string | null -} - export type StructuredAgentSessionRestartFailureLedger = { /** The stored records, current or not. */ read: () => Promise - /** The current records as rows a surface can show; sessions this host no longer holds are left - * out, and records the chat has since superseded are dropped and pruned. */ + /** The records as rows a surface can show; sessions this build cannot run are left out. */ list: () => Promise - /** One action's attempts; a chat never observed after an attempt falls back to its reserved - * marker. */ - attempts: ( - markers: ReadonlyMap - ) => StructuredAgentSessionRestartAttempts /** Settles one operation's reservations: the agent carried on, or the failure is filed. Rows the * operation still owns after that are reopened. */ settle: ( @@ -78,7 +49,8 @@ export type StructuredAgentSessionRestartFailureLedger = { outcomes: readonly StructuredAgentSessionResumeOutcome[], action: { candidates: readonly StructuredAgentSessionResumeCandidate[] - attempts: StructuredAgentSessionRestartAttempts + /** The reserved markers; a failure carries its marker's message id for older readers. */ + markers: ReadonlyMap /** How a reattached session's action ended; null when the agent carried on. Reattaching alone * is not the whole action, so the runner's own outcome cannot decide this. */ failureAfterResume: (sessionId: string) => AgentSessionResumeFailureOutcome | null @@ -99,54 +71,14 @@ export function continuationFailureOutcome( return outcome === 'continued' ? null : outcome === 'refused' ? 'refused' : 'unconfirmed' } -/** Whether the chat itself has moved past a failure: a newer user message, or, for a delivery - * nobody confirmed, a turn the continuation's own message opened. */ -function failureAnsweredByChat( - failure: AgentSessionResumeFailureRecord, - snapshot: AgentJournalSnapshot -): boolean { - const latest = latestStructuredAgentSessionUserItem(snapshot.items)?.itemId ?? null - if (latest !== failure.latestUserItemId) { - return true - } - // Only when the continuation's message was journaled: otherwise the newest user message is the - // interrupted one, whose own turn proves nothing about the continuation. - return ( - failure.outcome === 'unconfirmed' && - latest !== null && - latest !== failure.marker.latestUserItemId && - newestTurnUserItemId(snapshot) === latest - ) -} - -/** The user message that opened the newest root turn, resolved through a provider key the send was - * accepted under. */ -function newestTurnUserItemId(snapshot: AgentJournalSnapshot): string | null { - for (let index = snapshot.items.length - 1; index >= 0; index -= 1) { - const item = snapshot.items[index] - const turn = isRootAgentJournalItem(item) ? readAgentJournalTurn(item?.body) : null - if (!turn) { - continue - } - const key = turn.userItemId - if (key === undefined) { - return null - } - const accepted = snapshot.submissions.find((entry) => entry.providerItemId === key) - return accepted ? agentJournalSubmissionKey(accepted.clientMessageId) : key - } - return null -} - export function createStructuredAgentSessionRestartFailureLedger(deps: { capsule?: FailureCapsule - sessions: ReadonlyMap - /** Makes a persisted chat's journal readable here, as listing an offer does. */ - reveal: (sessionId: string) => Promise getRecord: (sessionId: string) => AgentSessionRecord | null adapter: StructuredAgentSessionAdapter /** The predicate a retry applies to the failure's marker. */ retryable: (marker: AgentSessionResumeMarker) => boolean + /** Makes the failed chats readable here, so `retryable` reads each one's journal. */ + reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise now: () => number /** The capsule's single mutation lane, shared with the offer's own operations. */ enqueue: (operation: () => Promise) => Promise @@ -184,41 +116,16 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { failedAt: failure.failedAt, outcome: failure.outcome, reason: failure.reason, + ...(failure.details ? { details: failure.details } : {}), retryable: deps.retryable(failure.marker) } ] } const list = async (): Promise => { - const current: AgentSessionResumeFailureRecord[] = [] - const superseded: AgentSessionResumeFailureRecord[] = [] - for (const failure of await read()) { - const sessionId = failure.marker.sessionId - if (!deps.sessions.has(sessionId)) { - await deps.reveal(sessionId) - } - const snapshot = deps.sessions.get(sessionId)?.journal.snapshot() - // An unreadable journal decides nothing; the record stays until something that can decide. - if (snapshot && failureAnsweredByChat(failure, snapshot)) { - superseded.push(failure) - } else { - current.push(failure) - } - } - const capsule = deps.capsule - if (capsule && superseded.length > 0) { - const gone = superseded.map((failure) => ({ - sessionId: failure.marker.sessionId, - recordedAt: failure.marker.recordedAt, - failedAt: failure.failedAt - })) - void deps - .enqueue(() => capsule.forgetSuperseded(gone, deps.now())) - .catch(() => { - console.warn('[structured-agent-session] pruning superseded restart failures failed') - }) - } - return current.flatMap(toRow) + const failures = await read() + await deps.reveal(failures.map((failure) => failure.marker)) + return failures.flatMap(toRow) } const settle: StructuredAgentSessionRestartFailureLedger['settle'] = async ( @@ -232,16 +139,22 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { } const completed: string[] = [] const failures: AgentSessionResumeFailureInput[] = [] - const promptBySession = new Map( - action.candidates.map((candidate) => [candidate.sessionId, candidate.latestPrompt]) - ) + // A retry keeps the prompt its first failure named: the chat's newest user message since then + // is the rejected continuation, and a message of the user's own would have ended the offer. + const promptBySession = new Map([ + ...action.candidates.map( + (candidate) => [candidate.sessionId, candidate.latestPrompt] as const + ), + ...(await read()).map((filed) => [filed.marker.sessionId, filed.latestPrompt] as const) + ]) for (const outcome of outcomes) { const resumed = outcome.outcome === 'resumed' - // Ineligible means the user moved on or the offer no longer applies (record gone, - // conversation forked), so there is nothing to retry and the offer is spent. + // Ineligible means the offer no longer applies (record gone, conversation forked), and + // superseded means the user's own message came first: nothing to retry, and the offer is spent. const failure = resumed ? action.failureAfterResume(outcome.sessionId) - : outcome.reason === STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE + : outcome.reason === STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE || + outcome.reason === RESTART_CONTINUATION_SUPERSEDED ? null : 'refused' if (failure === null) { @@ -255,8 +168,9 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { reason: resumed ? action.failureReason(outcome.sessionId) : (outcome.reason ?? 'agent_session_resume_refused'), + ...(!resumed && outcome.details ? { details: outcome.details } : {}), latestPrompt: promptBySession.get(outcome.sessionId) ?? '', - latestUserItemId: action.attempts.latestUserItemId(outcome.sessionId) + latestUserItemId: action.markers.get(outcome.sessionId)?.latestUserItemId ?? null }) } await deps @@ -280,27 +194,9 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { }) } - const attempts: StructuredAgentSessionRestartFailureLedger['attempts'] = (markers) => { - const observed = new Map() - return { - // Observed after the attempt, so the continuation's own message is part of the filed state. - observe: (sessionId) => { - const latest = liveStructuredAgentSessionLatestUserItemId(deps.sessions, sessionId) - if (latest !== undefined) { - observed.set(sessionId, latest) - } - }, - latestUserItemId: (sessionId) => { - const latest = observed.get(sessionId) - return latest !== undefined ? latest : (markers.get(sessionId)?.latestUserItemId ?? null) - } - } - } - return { read, list, - attempts, settle, dismiss: (sessionIds, beforeClearAll) => deps.enqueue(async () => { 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 1d7f0033a15..d88a6330e78 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 @@ -11,6 +11,7 @@ import { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { adapter, @@ -24,14 +25,30 @@ import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, + hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' -export const GRACE = 15_000 +/** 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. */ +export async function startAgent(state: { + host: StructuredAgentSessionHost + store: AgentSessionRecordStore +}): Promise { + const result = await state.host.attach( + CALLER, + hostTestAttachParams(state.store.getRecord(SESSION)?.lease.runtimeFence ?? null) + ) + expect(result.ok).toBe(true) +} export async function interruptedRestart( work: 'turn' | 'submission' | 'send-after-reply' | 'children' = 'turn', - historyBoundaryConsistent = true + historyBoundaryConsistent = true, + /** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */ + probeOwner: NonNullable = async () => ({ + outcome: 'pid-absent' + }) ) { const previous = hostTestState() await attach() @@ -98,6 +115,8 @@ export async function interruptedRestart( hostId: 'local' }) const closeSession = vi.fn(async () => true) + // The relaunch comes after the quit that recorded the offer. + const clock = { now: NOW + 1 } const host = new StructuredAgentSessionHost({ store, adapter: { @@ -116,10 +135,9 @@ export async function interruptedRestart( journalRoot: previous.root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', - probeOwner: async () => ({ outcome: 'pid-absent' }), + probeOwner, recoveryCapsule: new AgentSessionRecoveryCapsule(previous.root), - releaseGraceMs: GRACE, - now: () => NOW + now: () => clock.now }) replaceHostTestState({ store, host }) previous.acquire.mockClear() @@ -129,45 +147,34 @@ export async function interruptedRestart( await readFile(join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') ) const marker = parseAgentSessionResumeMarker(capsule.entries[0]?.marker) - return { ...hostTestState(), host, store, closeSession, marker } + return { ...hostTestState(), host, store, closeSession, marker, clock } } -export function statusNotes(host: StructuredAgentSessionHost) { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => - item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] - ) +export async function statusNotes(host: StructuredAgentSessionHost) { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] + ) } -/** A reattach that succeeds and a continuation the host refuses: a message from another client - * lands while the continuation is being recorded. `userAnswers` has the user reply in the chat - * just before or after its own attempt, while the rest of a batch would still be running. */ +/** A continuation the host refuses because the user's own message was accepted first: another + * client's send lands after the action reserved the offer, just before the continuation is + * accepted. `userAnswers` instead has the user send before or after the whole attempt. */ export async function supersededRefusal(userAnswers?: 'before' | 'after') { - const { host, acquire, dispatch, root } = await interruptedRestart() + const { host, store, acquire, dispatch, root } = await interruptedRestart() await host.restartResume.list() - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - // The newer message lands after the reattach and just before the continuation is accepted, - // which is where a send asks whether its offer still stands. - const send = host.send - const writing = vi.spyOn(host, 'send') - writing.mockImplementationOnce(async (caller, params) => { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'newer-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client') - ) - await host.flushStreamedEvents(SESSION) - return send(caller, params) - }) - const admit = StructuredAgentSessionResumeAdmission.prototype.run - const admitting = vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run') - const body = hostTestMessage('Carry on from where you stopped') + const body = hostTestMessage('A newer task from another client') const answer = () => host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + const send = host.send + const writing = vi.spyOn(host, 'send') + if (!userAnswers) { + writing.mockImplementationOnce(async (caller, params) => { + await answer() + return send(caller, params) + }) + } + const admit = StructuredAgentSessionResumeAdmission.prototype.run + const admitting = vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run') if (userAnswers) { admitting.mockImplementationOnce(async function (this, ...args) { await (userAnswers === 'before' ? answer() : null) @@ -180,9 +187,7 @@ export async function supersededRefusal(userAnswers?: 'before' | 'after') { } try { const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.continued).toMatchObject([{ outcome: 'refused' }]) - expect(dispatch).toHaveBeenCalledTimes(userAnswers ? 1 : 0) - return { host, root, result } + return { host, store, acquire, dispatch, root, result } } finally { writing.mockRestore() admitting.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts index 912594a1737..032b0e07b2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts @@ -1,99 +1,42 @@ -// An offer across the provider reattaching and rewriting the chat in its own words: a notice turn -// of its own, restated rows. None of it withdraws the offer or changes the message Resume sends. +// An offer across a failed attempt and its retry, and across builds: the message Resume sends +// depends on the marker alone, and each action sends its own. import { expect, it } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { AGENT_SESSION_RESTART_CONTINUATION_MESSAGE } from '../../../shared/agent-session-restart-continuation' -import { restartContinuationBody } from './structured-agent-session-restart-continuation' +import { restartContinuationBody } from './structured-agent-session-restart-continuation-envelope' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' import { HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD + HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' -function resumedEvents(state: Awaited>) { - // The latest acquisition: an earlier one may have been refused. - const events = state.acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - return events -} - -// THE REPORTED REFUSAL: opening the chat reattached Claude, which opened a turn of its own to say -// the previous session did not finish, and closed it. The chat is still offered and Resume sends — -// including when the offer is a send that had not become a turn, where that closed notice turn is -// the newest turn in the journal. -it.each(['turn', 'submission'] as const)( - 'still offers and continues a %s offer after the provider opens and closes a notice turn', - async (work) => { - const state = await interruptedRestart(work, false) - const { host, dispatch } = state - await host.hold(SESSION, 'pane') - const events = resumedEvents(state) - const notice = { provider: 'codex', threadId: THREAD, turnId: 'notice-turn' } as const - events.appendItem( - { ...notice, ordinal: 1 }, - { kind: 'turn', turnId: 'notice-turn', state: 'running', userItemId: 'turn:notice-turn' } - ) - events.appendItem( - { ...notice, ordinal: 2 }, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: "didn't finish before the previous session ended" }] - } - ) - events.appendItem( - { ...notice, ordinal: 1 }, - { kind: 'turn', turnId: 'notice-turn', state: 'completed', userItemId: 'turn:notice-turn' } - ) - await host.flushStreamedEvents(SESSION) - - expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) - expect( - (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued - ).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') - } -) - -// A retry after the first attempt never reached the provider, with the rows restated in between: -// the body depends on the marker alone, so the ledger fingerprint stays the offer's. -it('sends the same continuation body on a retry after the provider restates its rows', async () => { - const state = await interruptedRestart('children') - const { host, acquire, dispatch, marker } = state +// The first attempt's start failed, so its continuation was rejected and never reached the agent. +// A retry is a new action with a new message, not a replay of the rejected one, and it is +// delivered with the same body. +it("delivers a retry as a new continuation after the first one's start failed", async () => { + const { host, acquire, dispatch, marker } = await interruptedRestart('children') if (!marker) { throw new Error('missing interrupted restart marker') } acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) - await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: true }]) + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(first.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused', retryable: true }]) + const [rejected] = (await host.journalSnapshot(SESSION)).submissions + expect(rejected).toMatchObject({ dispatchState: 'rejected' }) - await host.hold(SESSION, 'pane') - resumedEvents(state).appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 2 }, - { - kind: 'message', - role: 'system', - blocks: [ - { - type: 'subagent-group', - groupId: 'settled-turn', - agents: [{ id: 'child-1', label: 'Review loop 4', state: 'completed' }] - } - ] - } - ) - await host.flushStreamedEvents(SESSION) const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') expect(retried.continued).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - expect(dispatch.mock.calls[0]?.[0].body).toEqual(restartContinuationBody(marker)) - host.release(SESSION, 'pane') + expect(dispatch).toHaveBeenCalledOnce() + const sent = dispatch.mock.calls[0]?.[0] + expect(sent?.clientMessageId).not.toBe(rejected?.clientMessageId) + expect(sent?.body).toEqual(restartContinuationBody(marker)) + expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ + { clientMessageId: rejected?.clientMessageId, dispatchState: 'rejected' }, + { clientMessageId: sent?.clientMessageId, dispatchState: 'accepted' } + ]) + expect(retried.failed).toEqual([]) }) // A marker from a build that recorded only a working lead: no snapshot, so no activity to name, 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 69f6f661c37..aefa34f4b67 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 @@ -1,10 +1,11 @@ -// The ways an offer ends. Every ending is the user's own act, and every ending DELETES the -// durable record — nothing stays behind to be re-filtered on every later read. +// The ways an offer ends. Every ending DELETES the durable record — nothing stays behind to be +// re-filtered on every later read. import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +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 { CALLER, envelope } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, @@ -12,36 +13,47 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' -// The user answering the chat themselves is the one signal the offer is moot. The next listing -// notices and deletes the durable record, not merely hides it. +// The user's own message starts the chat's agent again, which ends the offer: the durable record +// is deleted, not merely hidden (R-04). it('deletes the offer once the user sends their own message in that chat', async () => { const { host, root, dispatch } = await interruptedRestart() const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.list(NOW)).toHaveLength(1) - await host.hold(SESSION, 'pane') dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('Never mind, do this instead') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + // Written when the agent's start is proven: a cold start. + await vi.waitFor( + async () => { + expect(await capsule.list(NOW)).toEqual([]) + }, + { timeout: 10_000 } + ) expect(await host.restartResume.list()).toEqual([]) - await vi.waitFor(async () => { - expect(await capsule.list(NOW)).toEqual([]) - }) - host.release(SESSION, 'pane') }) -// A journal this host cannot read says nothing about the user moving on, so it must not end the offer. +// 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 } = await interruptedRestart('submission') + const { host, root, store } = await interruptedRestart('submission') const capsule = new AgentSessionRecoveryCapsule(root) - const restoring = vi - .spyOn(StructuredAgentSessionReadableRestorer.prototype, 'restoreOne') - .mockRejectedValue(new Error('journal unreadable')) + 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') try { expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) } finally { - restoring.mockRestore() + await rm(journalDir) + await rename(`${journalDir}.aside`, journalDir) } 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-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts new file mode 100644 index 00000000000..d36e5702d1d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -0,0 +1,548 @@ +// A restart offer ends when the chat moves on after the restart: another message accepted, or its +// agent started, other than by the offer's own continuation. Each case reads the offer list and the +// capsule, never only an in-memory set. + +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AgentSessionRecoveryCapsule, + AGENT_SESSION_RECOVERY_CAPSULE_FILE +} from '../../runtime/agent-session-recovery-capsule' +import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + interruptedRestart, + statusNotes +} from './structured-agent-session-restart-interruption-test-harness' +import { CALLER, envelope } from './structured-agent-session-host-test-harness' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' +import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' +import { restartContinuationId } from './structured-agent-session-restart-continuation-envelope' +import { sweepOnce } from './structured-agent-session-rest-test-rig' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +afterEach(() => vi.restoreAllMocks()) + +const SUPERSEDED = 'agent_session_restart_work_superseded' + +/** A start under a loaded machine: reconcile, acquire, hand over. */ +const COLD_START = { timeout: 10_000 } + +async function offered(work: 'turn' | 'submission' = 'turn') { + const state = await interruptedRestart(work, false) + expect(await state.host.restartResume.list()).toHaveLength(1) + return state +} + +function offersIn(root: string) { + return new AgentSessionRecoveryCapsule(root).list(NOW) +} + +function userSend(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +function compactParams() { + return { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } +} + +function sentTexts(dispatch: Awaited>['dispatch']): string[] { + return dispatch.mock.calls.map(([input]) => + input.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('') + ) +} + +it('withdraws the offer when /compact starts the agent at rest (R-01)', async () => { + const { host, root, acquire } = await offered() + Object.assign(host.deps.adapter, { compact: vi.fn(async () => ({})) }) + + expect(await host.conversationCommand(CALLER, compactParams())).toMatchObject({ ok: true }) + + expect(acquire).toHaveBeenCalledOnce() + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]), COLD_START) + expect(await host.restartResume.list()).toEqual([]) +}) + +it('keeps the offer through every way of looking at the chat (R-02)', async () => { + const { host, root, acquire } = await offered() + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + await host.readOptions(SESSION) + host.readCommands(SESSION) + await host.handoffStatus(SESSION) + await host.revealSession(SESSION) + unsubscribe() + + expect(acquire).not.toHaveBeenCalled() + expect(await host.restartResume.list()).toHaveLength(1) + expect(await offersIn(root)).toHaveLength(1) +}) + +it("does not let the offer's own continuation withdraw it (R-03)", async () => { + const { host, root, dispatch } = await offered() + const dismiss = vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'dismiss') + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + expect(dispatch).toHaveBeenCalledOnce() + // The start was the continuation's own: whatever asked, it withdrew nothing. + for (const call of dismiss.mock.results) { + expect(await call.value).toBe(0) + } + // Ended by the success path instead. + expect(await offersIn(root)).toEqual([]) +}) + +it('refuses the continuation when another start lands inside its action (R-03b)', async () => { + const { host, root, dispatch } = await offered() + Object.assign(host.deps.adapter, { compact: vi.fn(async () => ({})) }) + const send = host.send + vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { + // Another client's /compact starts the agent after the action reserved the offer. + expect(await host.conversationCommand(CALLER, compactParams())).toMatchObject({ ok: true }) + return send(caller, params) + }) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) + expect(dispatch).not.toHaveBeenCalled() + expect(await offersIn(root)).toEqual([]) + expect(result.failed).toEqual([]) +}) + +it('keeps the offer when a tabbed chat is restored at boot (R-05)', async () => { + const { host, store, root, acquire } = await offered() + await store.setSessionTabVisibility(SESSION, true) + + await host.restoreReadableSessions([SESSION]) + + expect(acquire).not.toHaveBeenCalled() + expect(await host.restartResume.list()).toHaveLength(1) + expect(await offersIn(root)).toHaveLength(1) +}) + +it('reads an older build’s marker whose message id no longer matches, and one with none (R-06)', async () => { + const { host, root, dispatch, marker } = await offered('submission') + if (!marker) { + throw new Error('missing interrupted restart marker') + } + // The older build compared this id with the chat's newest message; nothing does now. + await host.restartResume.dismiss([SESSION]) + await new AgentSessionRecoveryCapsule(root).record( + [{ ...marker, latestUserItemId: 'user-sent-under-the-older-build' }], + NOW + ) + expect(await host.restartResume.list()).toHaveLength(1) + const { latestUserItemId: _dropped, ...withoutId } = marker + expect(parseAgentSessionResumeMarker(withoutId)).toEqual(withoutId) + + await host.send(CALLER, userSend('Carry on')) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]), COLD_START) +}) + +it('still writes the message id the previous build requires on every marker', async () => { + const { root } = await offered('submission') + const capsule = JSON.parse( + await readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') + ) + expect(capsule.entries[0]?.marker).toHaveProperty('latestUserItemId') +}) + +/** Sends a message whose start fails, and waits for it to be rejected. */ +async function sendWhoseStartFails(state: Awaited>) { + state.acquire.mockRejectedValueOnce(new Error('Not signed in')) + const params = userSend('while signed out') + await state.host.send(CALLER, params) + await vi.waitFor(async () => + expect( + (await state.host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ dispatchState: 'rejected' }) + ) +} + +// A message the user sent is activity even when its start then failed. +it('withdraws the offer when the user sends a message whose start then fails (R-08)', async () => { + const state = await offered() + const { host, root, dispatch } = state + + await sendWhoseStartFails(state) + + expect(await host.restartResume.list()).toEqual([]) + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]), COLD_START) + // A click from a surface that still shows the offer finds nothing to act on. + expect(await host.restartResume.continueAfterRestart([SESSION], 'stale-click')).toMatchObject({ + resumed: [], + continued: [] + }) + expect(dispatch).not.toHaveBeenCalled() +}) + +// The idle sweep closes a chat's open handle, and the next read opens a new one: the user's message +// still counts, because it is read against where the journal stood when the offer was taken. +it('keeps the offer withdrawn after the idle sweep closes the chat and it is read again', async () => { + const state = await offered() + const { host, root, clock } = state + await sendWhoseStartFails(state) + clock.now += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + + await sweepOnce(host) + expect(host.hasSession(SESSION)).toBe(false) + await host.revealSession(SESSION) + + expect(await host.restartResume.list()).toEqual([]) + // The listing retires what it finds moved on; the write trails the answer. + await vi.waitFor(async () => expect(await offersIn(root)).toEqual([])) +}) + +// A failure the chat moved on from is read from its journal like an offer is, open or not. +it('keeps a failure not retryable after the user moved on and the sweep closed the chat', async () => { + const state = await offered() + const { host, clock } = state + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const resumed = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(resumed.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) + await sendWhoseStartFails(state) + expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: false }]) + clock.now += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 + + await sweepOnce(host) + expect(host.hasSession(SESSION)).toBe(false) + + expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: false }]) +}) + +const DAY_AND_AN_HOUR = 25 * 60 * 60 * 1000 + +// An offer has no expiry; the ledger refuses a new id dated more than a day back. +it('resumes more than a day after the quit', async () => { + const state = await offered() + const { host, clock, dispatch } = state + clock.now += DAY_AND_AN_HOUR + + const resumed = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(resumed.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) + expect(sentTexts(dispatch)).toHaveLength(1) + expect(await host.restartResume.listFailures()).toEqual([]) +}) + +// The offer remembers its own continuations; nothing about them expires before the offer does. +it('keeps a failed resume retryable more than a day later, after the ledger pruned its row', async () => { + const state = await offered() + const { host, clock, store } = state + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(first.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) + clock.now += DAY_AND_AN_HOUR + // What an app restart does to the operation ledger a day on. + await store.reconcileOnRestart({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reconciliation only reads the probe outcome. + probe: async () => ({ outcome: 'pid-absent' }) as never, + now: clock.now + }) + + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, retryable: true } + ]) + const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') + expect(retried.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) +}) + +// The start was the continuation's own even after the provider refused it: the child it started, +// running or since stopped, is still the offer's, so the offer stays retryable. +it('keeps a resume retryable when its agent started but refused the continuation', async () => { + const state = await offered() + const { host, store } = state + const acquire = state.acquire.getMockImplementation() + if (!acquire) { + throw new Error('the harness acquire has no implementation') + } + state.acquire.mockImplementationOnce(async (input) => ({ + ...(await acquire(input)), + acquisitionGeneration: 'generation-continuation' + })) + state.dispatch.mockResolvedValueOnce({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: { text: 'the provider refused the turn', audience: 'log' } + }), + { surface: 'rejection' } + ) + }) + + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(state.acquire).toHaveBeenCalledOnce() + expect(first.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, retryable: true } + ]) + // The child the continuation started exits after proving its start. + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: 'generation-continuation', + reason: 'codex app-server exited', + cause: 'unexpected-exit' + }) + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, retryable: true } + ]) + const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') + expect(retried.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) +}) + +// A continuation handed to the agent may have landed even with no answer yet, so a retry could send +// it twice: the failure it files is not retryable, as for one whose dispatch is in doubt. +it('keeps an unanswered continuation the agent was handed from being sent again', async () => { + const state = await offered() + const { host } = state + // The agent took it and never answered; the wait for that answer ended first (too many waited). + state.dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const wait = host.waitForSendSettlement + vi.spyOn(host, 'waitForSendSettlement').mockImplementation(async (...args) => + args[2]?.until === 'handed-over' ? wait(...args) : undefined + ) + + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(first.continued).toMatchObject([{ sessionId: SESSION, outcome: 'pending' }]) + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, outcome: 'unconfirmed', retryable: false } + ]) + expect(await host.restartResume.continueAfterRestart([SESSION], 'retry')).toMatchObject({ + continued: [] + }) + expect(state.dispatch).toHaveBeenCalledOnce() +}) + +// The rejected continuation is the chat's newest user message; the row still names the user's. +it("names the user's prompt on a failed retry, not the rejected continuation", async () => { + const state = await offered('submission') + const { host } = state + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(first.failed).toMatchObject([{ latestPrompt: 'Perform the original task' }]) + + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') + + expect(retried.failed).toMatchObject([ + { latestPrompt: 'Perform the original task', retryable: true } + ]) +}) + +// Resume that runs by itself at launch goes through the same call a click does, and the same rule +// decides: whichever of the two was accepted first since the restart wins. +it("refuses an automatic continuation when the user's message was accepted first, and writes nothing", async () => { + const { host, root, dispatch } = await offered() + const events: AgentSessionSubscribeEvent[] = [] + const unsubscribe = await host.subscribe({ + id: 'pane', + sessionId: SESSION, + emit: (event) => events.push(structuredClone(event)) + }) + const send = host.send + vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { + // Both accepted before any start, the user's first: the continuation queues behind the user's + // acceptance, ahead of the start that acceptance wakes. + let continuation: ReturnType | undefined + const append = AgentSessionJournal.prototype.appendSubmission + vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementationOnce( + async function (this: AgentSessionJournal, ...args) { + continuation = send(caller, params) + await new Promise((resolve) => setTimeout(resolve, 50)) + return append.apply(this, args) + } + ) + await send(CALLER, userSend('A new request')) + return continuation! + }) + + const result = await host.restartResume.continueAfterRestart(undefined, 'launch') + + expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) + expect(sentTexts(dispatch)).toEqual(['A new request']) + // Nothing the user did failed: no row reached the reader, and nothing is kept. + expect(await statusNotes(host)).toEqual([]) + expect( + events.some( + (event) => + event.type === 'batch' && event.batch.items.some((item) => item.body.kind === 'status') + ) + ).toBe(false) + expect(await offersIn(root)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) + unsubscribe() +}) + +it('delivers a clicked continuation and a message sent right after it, in the order accepted', async () => { + const { host, root, dispatch } = await offered() + const send = host.send + vi.spyOn(host, 'send').mockImplementationOnce(async (caller, params) => { + const continuation = send(caller, params) + const user = send(CALLER, userSend('And then this')) + await user + return continuation + }) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2), COLD_START) + const [first, second] = sentTexts(dispatch) + expect(first).not.toBe('And then this') + expect(second).toBe('And then this') + expect(await offersIn(root)).toEqual([]) +}) + +describe('reading the chat against where the offer was taken', () => { + const TAKEN = { epoch: 'epoch-1', sequence: 5 } + + const OFFER = { + sessionId: SESSION, + work: { kind: 'turn' as const, id: 'turn-1' }, + recordedAt: NOW, + trigger: 'quit' as const, + providerHandleRoot: 'codex:"thread"', + teardownId: 'teardown-1' + } + + function movedOn( + input: { + epoch?: string + submissions?: { + clientMessageId: string + acceptedSequence: number + dispatchState: string + handoverRecorded?: boolean + handedOverAt?: number + }[] + }, + journalCursor: { epoch: string; sequence: number } | null = TAKEN + ): boolean { + const session = { + child: null, + journal: { + cursor: () => ({ epoch: input.epoch ?? TAKEN.epoch, sequence: 9 }), + submissions: () => input.submissions ?? [] + } + } + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. + sessions: new Map([[SESSION, session as never]]), + now: () => NOW, + enqueue: (operation) => operation() + }) + return withdrawal.movedOn({ ...OFFER, ...(journalCursor ? { journalCursor } : {}) }) + } + + it('counts a message accepted after that position, and nothing before it', () => { + const at = (acceptedSequence: number) => ({ + clientMessageId: `m-${acceptedSequence}`, + acceptedSequence, + dispatchState: 'rejected' + }) + expect(movedOn({ submissions: [at(4), at(5)] })).toBe(false) + expect(movedOn({ submissions: [at(4), at(6)] })).toBe(true) + }) + + it('counts a journal on another epoch as moved on', () => { + expect(movedOn({ epoch: 'epoch-2' })).toBe(true) + }) + + it("does not count the offer's own continuation while queued or rejected, so a retry still runs", () => { + const own = restartContinuationId(OFFER, 'operation-1', NOW) + const rejected = { clientMessageId: own, acceptedSequence: 7, dispatchState: 'rejected' } + const queued = { ...rejected, dispatchState: 'pending', handoverRecorded: true } + expect(movedOn({ submissions: [rejected] })).toBe(false) + expect(movedOn({ submissions: [queued] })).toBe(false) + // Handed over, it may have reached the agent, answered or not. + expect(movedOn({ submissions: [{ ...queued, handedOverAt: NOW }] })).toBe(true) + expect(movedOn({ submissions: [{ ...rejected, dispatchState: 'accepted' }] })).toBe(true) + // Another offer's continuation, and any other message, is the chat moving on. + const other = restartContinuationId({ ...OFFER, teardownId: 'teardown-2' }, 'operation-1', NOW) + expect(movedOn({ submissions: [{ ...rejected, clientMessageId: other }] })).toBe(true) + }) + + // The retry's reservation lapses with the app, and the capsule hands back the failure's marker, + // written before the retry ran. The next launch rejects the retry's queued continuation. + it('keeps a failure retryable after the app crashed during its retry', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-crash-during-retry-')) + try { + const capsule = new AgentSessionRecoveryCapsule(root) + await capsule.record([{ ...OFFER, journalCursor: TAKEN }], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + await capsule.failResume( + 'operation-a', + [ + { + sessionId: SESSION, + failedAt: NOW, + outcome: 'refused', + reason: 'x', + latestPrompt: '', + latestUserItemId: null + } + ], + NOW + ) + const [retrying] = await capsule.beginResume([SESSION], 'operation-b', NOW + 1) + if (!retrying) { + throw new Error('the retry reserved nothing') + } + const retry = restartContinuationId(retrying, 'operation-b', NOW + 1) + const afterCrash = NOW + 60 * 60_000 + expect(await capsule.list(afterCrash)).toEqual([]) + const [failed] = await capsule.listFailed(afterCrash) + + const session = { + child: null, + journal: { + cursor: () => ({ epoch: TAKEN.epoch, sequence: 9 }), + submissions: () => [ + { clientMessageId: retry, acceptedSequence: 7, dispatchState: 'rejected' } + ] + } + } + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fact reads only the journal's cursor and submissions and the child fields given here. + sessions: new Map([[SESSION, session as never]]), + now: () => afterCrash, + enqueue: (operation) => operation() + }) + if (!failed) { + throw new Error('the failure did not outlive the crash') + } + expect(withdrawal.movedOn(failed.marker)).toBe(false) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('leaves an older build’s offer, which recorded no position, to a start', () => { + const after = { clientMessageId: 'm-7', acceptedSequence: 7, dispatchState: 'accepted' } + expect(movedOn({ submissions: [after], epoch: 'epoch-2' }, null)).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts new file mode 100644 index 00000000000..76d8006d9a1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -0,0 +1,92 @@ +// When a restart offer ends without being acted on: the chat moved on. +// +// One derived fact decides it, for the offer list and for the continuation's own acceptance alike: +// since the offer was taken, another message was accepted in the chat, or its agent proved a start. +// A message is read against the journal position the offer recorded, so the answer survives any +// number of handle closes. A start is known only to the host that saw it, so it is also written +// to the recovery file when it happens. A restored row, a replayed row or a view carries neither, +// so opening a chat withdraws nothing. The rest of a resume action does not count: its own +// continuation, and the start that continuation waits on. + +import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { isRestartContinuationOf } from './structured-agent-session-restart-continuation-envelope' + +export type StructuredAgentSessionRestartOfferWithdrawal = ReturnType< + typeof createStructuredAgentSessionRestartOfferWithdrawal +> + +export type StructuredAgentSessionRestartOfferSession = Pick< + StructuredAgentSessionHostSession, + 'journal' | 'child' | 'lastEndedChild' +> + +export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { + sessions: ReadonlyMap + capsule?: Pick + now: () => number + /** The capsule's single mutation lane, shared with the offer's own operations. */ + enqueue: (operation: () => Promise) => Promise +}) { + const movedOn = (marker: AgentSessionResumeMarker): boolean => { + const session = deps.sessions.get(marker.sessionId) + if (!session) { + return false + } + const taken = marker.journalCursor + // An older build's offer recorded no position: only a start withdraws it. + const accepted = + taken !== undefined && + (session.journal.cursor().epoch !== taken.epoch || + session.journal.submissions().some( + (submission) => + (submission.acceptedSequence ?? 0) > taken.sequence && + // The offer's own continuation, still queued or rejected, never reached the agent: a + // retry sends a new one. One handed over may have, answered or not. + !( + (isQueuedAgentJournalSubmission(submission) || + submission.dispatchState === 'rejected') && + isRestartContinuationOf(marker, submission.clientMessageId) + ) + )) + // Proven, not merely spawned: a start that failed during startup never ran the agent. Whose + // start it was is fixed when it was made, so a continuation rejected since still owns it. + const started = + session.child?.phase === 'ready' + ? session.child + : session.lastEndedChild?.duringStartup === false + ? session.lastEndedChild + : undefined + return ( + accepted || + (started !== undefined && + !(started.startedFor !== undefined && isRestartContinuationOf(marker, started.startedFor))) + ) + } + + return { + movedOn, + /** The chat's agent proved a start: the offer and any failure record go from the recovery file, + * unless the start was for one of that offer's own continuations. Advisory: a failed write is + * logged, never raised. */ + onAgentStarted: (sessionId: string): void => { + const session = deps.sessions.get(sessionId) + const capsule = deps.capsule + if (!capsule || !session) { + return + } + const startedFor = session.child?.startedFor + void deps + .enqueue(() => + capsule.dismiss([sessionId], deps.now(), (marker) => + startedFor === undefined ? false : isRestartContinuationOf(marker, startedFor) + ) + ) + .catch(() => { + console.warn('[structured-agent-session] withdrawing a restart offer failed') + }) + } + } +} 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 ca5ba68dee0..b45e08314a0 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 @@ -7,8 +7,6 @@ import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-res import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { pendingApproval } from './structured-agent-session-restart-resume-test-harness' -import { restartContinuationEnvelope } from './structured-agent-session-restart-continuation' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -16,8 +14,8 @@ import { } from '../../../shared/agent-session-restart-continuation' import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { - GRACE, interruptedRestart, + startAgent, statusNotes, supersededRefusal } from './structured-agent-session-restart-interruption-test-harness' @@ -33,6 +31,8 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' afterEach(() => vi.useRealTimers()) @@ -40,7 +40,7 @@ it('publishes continuation attribution to the subscribed chat without another pr const { host } = await interruptedRestart() await host.restartResume.list() const emit = vi.fn() - const unsubscribe = host.subscribe({ id: 'pane', sessionId: SESSION, emit }) + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit }) try { expect( (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued @@ -81,274 +81,75 @@ it.each(['turn', 'submission'] as const)( async (work) => { const { host, dispatch } = await interruptedRestart(work, false) expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') + await startAgent(hostTestState()) dispatch.mockResolvedValueOnce({ state: 'admitted' }) const body = hostTestMessage('Stop the old task and do this instead') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(dispatch).toHaveBeenCalledTimes(1) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(work === 'turn' ? 1 : 2) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(work === 'turn' ? 1 : 2) } ) -it('preserves the offer when the original submission receives its provider echo', async () => { - const { host, acquire, dispatch } = await interruptedRestart('submission', false) - expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - hostTestMessage('Perform the original task') - ) - await host.flushStreamedEvents(SESSION) - expect(host.journalSnapshot(SESSION).submissions[0]?.dispatchState).toBe('accepted') - expect( - (await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued - ).toMatchObject([{ outcome: 'continued' }]) - expect(acquire).toHaveBeenCalledTimes(1) - expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) -}) - -// Teardown judged the chat working, and only the user moving on withdraws that. A send the -// provider proves it never received is still the chat Orca stopped; the continuation asks the agent +// Teardown judged the chat working. A send the provider proves it never received is still the chat +// Orca stopped; the continuation asks the agent // to check what finished rather than refusing. it('still continues a chat whose last send acquisition proves was never delivered', async () => { const { host, acquire, dispatch } = await interruptedRestart('submission') expect(await host.restartResume.list()).toHaveLength(1) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(host.journalSnapshot(SESSION).submissions[0]).toMatchObject({ + expect((await host.journalSnapshot(SESSION)).submissions[0]).toMatchObject({ dispatchState: 'rejected', - reason: 'not_delivered' + rejection: { kind: 'notDelivered' } }) expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) expect(result.continued).toMatchObject([{ outcome: 'continued' }]) - expect(host.isHeld(SESSION)).toBe(false) }) -/** Holds the continuation at send admission while `during` runs, then lets it proceed. */ -async function continueAcross( - state: Awaited>, - during: (events: NonNullable>) => void -) { - const { host, store } = state - expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') - const events = resumedEvents(state) - if (!events) { - throw new Error('missing resumed provider event sink') - } - const admitting = Promise.withResolvers() - const proceed = Promise.withResolvers() - const admit = store.admitMutationOperation - vi.spyOn(store, 'admitMutationOperation').mockImplementationOnce(async (input) => { - admitting.resolve() - await proceed.promise - return admit(input) - }) - const continuing = host.restartResume.continueAfterRestart([SESSION], 'modal') - await admitting.promise - during(events) - await host.flushStreamedEvents(SESSION) - proceed.resolve() - return continuing -} - -function resumedEvents(state: Awaited>) { - return state.acquire.mock.calls[0]?.[0].events -} - -it('refuses at send admission once the user has sent a newer message', async () => { - const state = await interruptedRestart() - const { host, dispatch } = state - const result = await continueAcross(state, (events) => - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'newer-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client') - ) - ) - expect(result.resumed).toMatchObject([ - { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ]) - expect(dispatch).not.toHaveBeenCalled() - // Refused where it would have been accepted, so the chat records no continuation at all. - expect(host.journalSnapshot(SESSION).submissions).toEqual([]) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) -}) - -// Claude opens a turn of its own on reattach to say the last session did not finish. Resume is -// pressed while that turn still runs: the continuation is sent and the provider queues it. -it('sends the continuation while a turn the provider opened is still running', async () => { - const state = await interruptedRestart() - const { host, dispatch } = state - const result = await continueAcross(state, (events) => - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'notice-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'notice-turn', state: 'running' } - ) - ) - expect(result.continued).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') -}) - -// The send the user made just before quitting, after an earlier exchange had finished. Neither -// that finished turn nor the provider completing the send's own turn after the restart withdraws it. +// The send the user made just before quitting, after an earlier exchange had finished: that +// finished turn does not withdraw it. it('offers and continues a send made after an earlier completed turn', async () => { - const state = await interruptedRestart('send-after-reply', false) - const { host, dispatch, marker } = state + const { host, dispatch, marker } = await interruptedRestart('send-after-reply', false) expect(marker?.work.kind).toBe('submission') - const result = await continueAcross(state, (events) => - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'original-turn', state: 'completed' } - ) - ) + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.continued).toMatchObject([{ outcome: 'continued' }]) expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') }) -it.each([false, true])( - 'refuses a queued newer message before dispatch even if later settlement fails: %s', - async (settlementFails) => { - const { host, store, acquire, dispatch } = await interruptedRestart('submission', false) - expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const settle = store.recordOperationOutcome.bind(store) - let admitted = false - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (admitted) { - if (settlementFails) { - throw new Error('operation outcome could not be persisted') - } - return settle(input) - } - await settle(input) - admitted = true - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 2 }, - { kind: 'status', text: 'Provider finished its last action' } - ) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, - hostTestMessage('A newer task from another client'), - { lifecycle: true } - ) - // Journaled before the continuation's acceptance asks whether its offer still stands. - await host.flushStreamedEvents(SESSION) - }) - - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - - expect(result.continued).toMatchObject([ - { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ]) - expect(dispatch).not.toHaveBeenCalled() - // Refused before acceptance: only the interrupted send is in the journal. - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) - // The offer is spent, but the refusal is kept as a durable failure: a retry finds it, and the - // superseded chat is still not eligible, so nothing runs and the record stays for the user. - expect(await host.restartResume.continueAfterRestart([SESSION], 'retry')).toMatchObject({ - resumed: [], - continued: [], - sessions: [], - failed: [ - { sessionId: SESSION, outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ] - }) - expect(dispatch).not.toHaveBeenCalled() - if (settlementFails) { - expect(store.recordOperationOutcome).toHaveBeenCalledOnce() - expect(warning).not.toHaveBeenCalled() - } - warning.mockRestore() - } -) - -// What the provider says after reattaching does not re-judge the offer. -it.each(['completed', 'approval', 'question'] as const)( - 'continues past provider %s evidence accepted while recording the continuation', - async (event) => { - const { host, acquire, dispatch } = await interruptedRestart() - await host.restartResume.list() - await host.hold(SESSION, 'pane') - const events = acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing resumed provider event sink') - } - const append = AgentSessionJournal.prototype.appendSubmission - const writing = vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission') - writing.mockImplementationOnce(async function (this: AgentSessionJournal, input) { - const cursor = await append.call(this, input) - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, - event === 'completed' - ? { kind: 'turn', turnId: 'interrupted-turn', state: 'completed' } - : { ...pendingApproval().body, question: 'Which action?', kind: event }, - { lifecycle: true } - ) - return cursor - }) - try { - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(result.continued).toMatchObject([{ outcome: 'continued' }]) - expect(dispatch).toHaveBeenCalledTimes(1) - } finally { - writing.mockRestore() - host.release(SESSION, 'pane') - } - } -) - it('replays the same logical continuation through the durable send ledger', async () => { - const { host, store, dispatch, marker } = await interruptedRestart() - if (!marker) { - throw new Error('missing interrupted restart marker') - } + const { host, dispatch } = await interruptedRestart() + const sending = vi.spyOn(host, 'send') await host.restartResume.continueAfterRestart([SESSION], 'modal') - const fence = store.getRecord(SESSION)?.lease.runtimeFence - if (fence === undefined) { - throw new Error('missing resumed lease') + const sent = sending.mock.calls[0]?.[1] + sending.mockRestore() + if (!sent) { + throw new Error('the continuation was not sent') } const replay = await host.send( { callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, - restartContinuationEnvelope(SESSION, fence, marker) + { envelope: sent.envelope, body: sent.body } ) expect(replay).toMatchObject({ ok: true, replayed: true }) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) expect(dispatch).toHaveBeenCalledTimes(1) }) +// A send that throws after Orca may have taken it is not proof it was not delivered. it.each([false, true])( - 'keeps dispatched delivery unconfirmed when settlement fails (uncertainty write fails: %s)', + 'keeps a continuation unconfirmed when its acceptance cannot be recorded (uncertainty write fails: %s)', async (uncertaintyFails) => { - const { host, store, dispatch } = await interruptedRestart() + const { host, store } = await interruptedRestart() const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') const settle = store.recordOperationOutcome.bind(store) - let dispatched = false - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded') { - dispatched = true - } - if (dispatched && (input.outcome.status === 'succeeded' || uncertaintyFails)) { + const recording = vi.spyOn(store, 'recordOperationOutcome') + recording.mockImplementation(async (input) => { + // Only the continuation's own record: the start it makes records its attach as usual. + if ( + input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && + (input.outcome.status === 'succeeded' || uncertaintyFails) + ) { throw new Error('operation outcome could not be persisted') } return settle(input) @@ -356,127 +157,96 @@ it.each([false, true])( const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(dispatch).toHaveBeenCalledTimes(1) - expect(host.journalSnapshot(SESSION).submissions[0]?.dispatchState).toBe('accepted') expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) - // Filed as unconfirmed, with a warning in the chat; the continuation's own message is part of - // the filed state, so it does not retire the record it caused. + // Filed as unconfirmed, with a warning in the chat. expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - expect(statusNotes(host)).toContainEqual({ + expect(await statusNotes(host)).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, tone: 'warning' }) - host.release(SESSION, 'pane') - expect(host.isHeld(SESSION)).toBe(false) - await host.restartResume.continueAfterRestart([SESSION], 'retry') - expect(dispatch).toHaveBeenCalledTimes(1) - expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) - expect(warning.mock.calls.flat()).not.toContainEqual( - expect.objectContaining({ message: 'operation outcome could not be persisted' }) - ) + recording.mockRestore() warning.mockRestore() } ) -it('releases a failed acquisition and leaves the offer retryable', async () => { +// The continuation is accepted, then its start fails: the message is rejected with the cause and +// the failure is filed, and nothing is stopped because nothing started. +it('rejects the continuation when its start fails, and files a retryable refusal', async () => { const { host, acquire, dispatch, closeSession } = await interruptedRestart() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) - expect(await host.restartResume.resume([SESSION], 'modal')).toMatchObject([ - { outcome: 'refused' } + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.resumed).toMatchObject([{ outcome: 'refused' }]) + expect(result.continued).toMatchObject([{ outcome: 'refused' }]) + // Nothing ran, so the offer stands as a failure a retry can act on. + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused', retryable: true }]) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) + expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ + { dispatchState: 'rejected' } ]) - expect(host.isHeld(SESSION)).toBe(false) - await host.restartResume.continueAfterRestart([SESSION], 'retry') - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(1) + expect(acquire).toHaveBeenCalledTimes(1) + expect(dispatch).not.toHaveBeenCalled() expect(closeSession).not.toHaveBeenCalled() }) -it.each([false, true])( - 'admits one durable continuation under concurrent calls (pane already live: %s)', - async (alreadyLive) => { - const { host, root, acquire, dispatch } = await interruptedRestart() - if (alreadyLive) { - expect(await host.restartResume.list()).toHaveLength(1) - await host.hold(SESSION, 'pane') - } - const results = await Promise.all([ - host.restartResume.continueAfterRestart([SESSION], 'window-one'), - host.restartResume.continueAfterRestart([SESSION], 'window-two') - ]) - expect( - results.flatMap((result) => result.resumed).filter((r) => r.outcome === 'resumed') - ).toHaveLength(1) - expect( - results.flatMap((result) => result.continued).filter((r) => r.outcome === 'continued') - ).toHaveLength(1) - expect(acquire).toHaveBeenCalledTimes(1) - expect(dispatch).toHaveBeenCalledTimes(1) - expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) - expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) - await host.restartResume.continueAfterRestart([SESSION], 'later-click') - expect(dispatch).toHaveBeenCalledTimes(1) - host.release(SESSION, 'pane') - } -) +it('admits one durable continuation under concurrent calls', async () => { + const { host, root, acquire, dispatch } = await interruptedRestart() + const results = await Promise.all([ + host.restartResume.continueAfterRestart([SESSION], 'window-one'), + host.restartResume.continueAfterRestart([SESSION], 'window-two') + ]) + expect( + results.flatMap((result) => result.resumed).filter((r) => r.outcome === 'resumed') + ).toHaveLength(1) + expect( + results.flatMap((result) => result.continued).filter((r) => r.outcome === 'continued') + ).toHaveLength(1) + expect(acquire).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledTimes(1) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) + await host.restartResume.continueAfterRestart([SESSION], 'later-click') + expect(dispatch).toHaveBeenCalledTimes(1) +}) -it.each([false, true])( - 'releases reconnect acquisition to idle eviction (pane: %s)', - async (pane) => { - const { host, acquire, dispatch, closeSession } = await interruptedRestart() - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - expect(await host.restartResume.resume([SESSION], 'modal')).toMatchObject([ - { outcome: 'resumed' } - ]) - expect(host.isHeld(SESSION)).toBe(false) - if (pane) { - await host.hold(SESSION, 'pane') - await vi.advanceTimersByTimeAsync(GRACE * 2) - expect(closeSession).not.toHaveBeenCalled() - host.release(SESSION, 'pane') - } - await vi.advanceTimersByTimeAsync(GRACE) - await vi.waitFor(() => expect(closeSession).toHaveBeenCalledTimes(1)) - expect(acquire).toHaveBeenCalledTimes(1) - expect(dispatch).not.toHaveBeenCalled() - } -) - -it('retains acquisition through slow continuation settlement, then releases it', async () => { - const { host, dispatch, closeSession } = await interruptedRestart() - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) +// The batch counts a chat done once its agent took the continuation; the provider's slow answer is +// awaited after, and decides the outcome. +it('waits out a slow provider answer before reporting the continuation', async () => { + const { host, dispatch } = await interruptedRestart() const settlement = Promise.withResolvers>>() const dispatched = Promise.withResolvers() dispatch.mockImplementationOnce(() => { dispatched.resolve() return settlement.promise }) - const continuing = host.restartResume.continueAfterRestart([SESSION], 'modal') + let settled = false + const continuing = host.restartResume + .continueAfterRestart([SESSION], 'modal') + .finally(() => (settled = true)) await dispatched.promise - await vi.advanceTimersByTimeAsync(GRACE * 2) - expect(host.isHeld(SESSION)).toBe(true) - expect(closeSession).not.toHaveBeenCalled() - settlement.resolve({ state: 'rejected', reason: 'provider refused' }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(settled).toBe(false) + settlement.resolve({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + }) expect((await continuing).continued).toMatchObject([{ outcome: 'refused' }]) - expect(host.isHeld(SESSION)).toBe(false) - await vi.advanceTimersByTimeAsync(GRACE) - await vi.waitFor(() => expect(closeSession).toHaveBeenCalledTimes(1)) expect(dispatch).toHaveBeenCalledTimes(1) }) -// Opening the chat is inspection only. The explicit restart action is what removes the durable -// offer, so ordinary pane lifecycle must not make this status disappear. +// Opening the chat is inspection only: it starts nothing, and the explicit restart action is what +// removes the durable offer, so reading the chat must not make this status disappear. it('keeps offering a chat after the user opens it', async () => { - const { host, root, store } = await interruptedRestart() + const { host, root, acquire } = await interruptedRestart() expect(await host.restartResume.list()).toHaveLength(1) - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - await host.hold(SESSION, 'pane') - host.release(SESSION, 'pane') - await vi.advanceTimersByTimeAsync(GRACE) - // The whole eviction, not just the provider stop: the lease returns to `released` on the step - // before the last, and until it does the offer is refused for a reason that is not recovery. - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - vi.useRealTimers() + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + unsubscribe() + expect(acquire).not.toHaveBeenCalled() expect(await host.restartResume.list()).toHaveLength(1) await host.restartResume.recordMarkers() @@ -545,7 +315,7 @@ it('keeps concurrent recovery reads independent and non-destructive', async () = list.mockRestore() }) -it('fails closed on corrupt recovery storage while ordinary hold and send still work', async () => { +it('fails closed on corrupt recovery storage while an ordinary send still works', async () => { const { host, root, dispatch } = await interruptedRestart() await writeFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), '{') const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -556,81 +326,56 @@ it('fails closed on corrupt recovery storage while ordinary hold and send still sessions: [], failed: [] }) - await host.hold(SESSION, 'pane') const body = hostTestMessage('A fresh ordinary request') expect( await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: true }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - // list; the action's read of offers and of failures; the post-action refresh of both. - expect(warning).toHaveBeenCalledTimes(5) + // list; the action's read of offers and of failures; the post-action refresh of both. The send + // cannot withdraw an offer it cannot read either, and says so. + const withdrawing = '[structured-agent-session] withdrawing a restart offer failed' + await vi.waitFor(() => expect(warning).toHaveBeenLastCalledWith(withdrawing)) + expect(warning.mock.calls.filter(([message]) => message !== withdrawing)).toHaveLength(5) warning.mockRestore() - host.release(SESSION, 'pane') }) -// The toast is gone in seconds and the offer is spent by the reattach, so without this record -// nothing on any surface would still name the chat the user has to continue by hand. -it('keeps a refused continuation as a durable failure that names the chat and the reason', async () => { - const { host, root, result } = await supersededRefusal() - const failure = { - sessionId: SESSION, - outcome: 'refused', - reason: 'agent_session_restart_work_superseded', - latestPrompt: expect.any(String), - agent: 'codex', - retryable: false - } - expect(result).toMatchObject({ sessions: [], failed: [failure] }) - // The chat itself says what happened and what to do. - expect(statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, - tone: 'error' +// The user's own message was accepted first: the continuation is refused, and since nothing the +// user did failed, the chat says nothing and no failure is kept. +it("refuses a continuation quietly when the user's own message was accepted first", async () => { + const { host, root, dispatch, result } = await supersededRefusal() + expect(result).toMatchObject({ + continued: [{ outcome: 'refused', reason: 'agent_session_restart_work_superseded' }], + sessions: [], + failed: [] }) - expect(await host.restartResume.list()).toEqual([]) - expect(await host.restartResume.listFailures()).toMatchObject([failure]) - // Durable: a fresh reader of the same file sees it too. - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toMatchObject([ - { marker: { sessionId: SESSION }, outcome: 'refused' } - ]) - host.release(SESSION, 'pane') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(await statusNotes(host)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) -// The failure asked the user to continue the chat themselves; their own message is that -// continuation. Nothing on the send path clears it: the listing sees the newer message. +/** A continuation whose start failed, filed as a retryable failure. */ +async function failedContinuation() { + const state = await interruptedRestart() + state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const result = await state.host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused' }]) + return state +} + +// Starting the chat's agent again answers a failure, as it ends an offer. it('retires a recorded failure once the user sends in that chat, with no send hook', async () => { - const { host, root } = await supersededRefusal() + const { host, root, dispatch } = await failedContinuation() const body = hostTestMessage('Carry on from where you stopped') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - expect(await host.restartResume.listFailures()).toEqual([]) - // Pruned from the file too, not only hidden. + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + // Pruned from the file, not only hidden. await vi.waitFor(async () => { expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) - host.release(SESSION, 'pane') + expect(await host.restartResume.listFailures()).toEqual([]) }) -// The user can reply in a chat while other chats in the same action are still being continued, -// before its turn in the batch or after its own note asks them to. Either reply answers the failure. -it.each(['before', 'after'] as const)( - 'retires a failure the user answered %s its own attempt, before the action settled', - async (userAnswers) => { - const { host, root, result } = await supersededRefusal(userAnswers) - expect(result.resumed).toMatchObject([ - userAnswers === 'before' ? { reason: 'agent_session_resume_not_eligible' } : {} - ]) - expect( - statusNotes(host).some( - (note) => note.text === AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE - ) - ).toBe(userAnswers === 'after') - expect(result.failed).toEqual([]) - await vi.waitFor(async () => { - expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) - }) - host.release(SESSION, 'pane') - } -) - it('removes a failure when a named retry succeeds', async () => { const { host, root, dispatch } = await interruptedRestart() const capsule = new AgentSessionRecoveryCapsule(root) @@ -646,7 +391,7 @@ it('removes a failure when a named retry succeeds', async () => { outcome: 'refused', reason: 'agent_session_conflict', latestPrompt: '', - latestUserItemId: pending!.latestUserItemId + latestUserItemId: pending!.latestUserItemId ?? null } ], NOW @@ -662,7 +407,7 @@ it('removes a failure when a named retry succeeds', async () => { }) it('dismisses one failure by name and leaves the rest of the durable records alone', async () => { - const { host, root } = await supersededRefusal() + const { host, root } = await failedContinuation() const capsule = new AgentSessionRecoveryCapsule(root) const other = parseAgentSessionResumeMarker({ sessionId: 'session-other', @@ -681,7 +426,6 @@ it('dismisses one failure by name and leaves the rest of the durable records alo expect(await host.restartResume.dismiss([SESSION])).toBe(1) expect(await host.restartResume.listFailures()).toEqual([]) expect(await capsule.list(NOW)).toEqual([other]) - host.release(SESSION, 'pane') }) it('logs teardown capsule publication failure and still releases the provider', async () => { @@ -700,7 +444,9 @@ it('logs teardown capsule publication failure and still releases the provider', await mkdir(capsulePath) const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect(previous.host.flushAllStreamedEvents()).resolves.toBeUndefined() - expect(() => previous.host.journalSnapshot(SESSION)).toThrow('agent_session_ownership_unknown') + await expect(previous.host.journalSnapshot(SESSION)).rejects.toThrow( + 'agent_session_ownership_unknown' + ) expect(warning).toHaveBeenCalledWith( '[structured-agent-session] recording recovery capsule failed' ) @@ -709,3 +455,29 @@ it('logs teardown capsule publication failure and still releases the provider', warning.mockRestore() await rm(capsulePath, { recursive: true }) }) + +// The resume ledger's reason stays the refusal code, which is what every renderer's guidance keys +// on; the details are filed beside it, never in its place. +it('files a restart refused by a conflicted claim under its code, with its details beside it', async () => { + // The terminal agent that holds the claim is still running, so nothing may take it over. + const { host, store } = await interruptedRestart('turn', true, async () => ({ + outcome: 'identity-matched', + matchedOn: ['spawn-token'] + })) + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { + ...record.lease, + claimStatus: 'conflicted', + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'terminal' } + } + })) + + await host.restartResume.continueAfterRestart([SESSION], 'modal') + + await vi.waitFor(async () => + expect(await host.restartResume.listFailures()).toMatchObject([ + { reason: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + ]) + ) +}) 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 491a2670d0d..90875832c8f 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 @@ -10,10 +10,7 @@ vi.mock('./structured-agent-session-read-restore', () => ({ restoreStructuredAgentSessionRead: restoreRead })) -import { - restoreOneStructuredAgentSessionRead, - restoreStructuredAgentSessionsOnRestart -} from './structured-agent-session-restart-restore' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' const NO_OPEN_DEPS = { store: { getRecord: () => null, listRecords: () => [] }, @@ -97,21 +94,20 @@ describe('restart journal restoration', () => { return restored }) - await restoreOneStructuredAgentSessionRead( - { - openDeps: NO_OPEN_DEPS, - reconcile: async () => null, - resolveRecovery: async () => { - calls.push('resolveRecovery') - }, - serialize: async (_sessionId, task) => task(), - hasSession: () => false, - onReadable: (_sessionId, readable) => { - calls.push(readable === restored ? 'onReadable:restored' : 'onReadable') - } + await restoreStructuredAgentSessionsOnRestart({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + records: [{ sessionId: 'session-1' } as AgentSessionRecord], + openDeps: NO_OPEN_DEPS, + reconcile: async () => null, + resolveRecovery: async () => { + calls.push('resolveRecovery') }, - 'session-1' - ) + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: (_sessionId, readable) => { + calls.push(readable === restored ? 'onReadable:restored' : 'onReadable') + } + }) expect(calls).toEqual(['resolveRecovery', 'open', 'onReadable:restored']) }) @@ -122,17 +118,16 @@ describe('restart journal restoration', () => { reset: null }) - await restoreOneStructuredAgentSessionRead( - { - openDeps: NO_OPEN_DEPS, - reconcile: async () => null, - resolveRecovery: async () => undefined, - serialize: async (_sessionId, task) => task(), - hasSession: () => true, - onReadable: () => undefined - }, - 'session-1' - ) + await restoreStructuredAgentSessionsOnRestart({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + records: [{ sessionId: 'session-1' } as AgentSessionRecord], + openDeps: NO_OPEN_DEPS, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => true, + onReadable: () => undefined + }) // The open is where the settlement runs, and a session already open is not opened again. expect(restoreRead).not.toHaveBeenCalled() 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 789eb37f45c..d54687c25a6 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 @@ -8,8 +8,8 @@ // It does not owe a provider child. This used to resume every record whose lease was `released`, // which is the normal end state of a chat the user closed cleanly — so a // healthy profile started an app-server per session it had ever used, in parallel, at every launch, -// with no client attached and nothing on screen. A child now exists because a surface asked for the -// session (see `structured-agent-session-holds`), not because a record survived on disk. +// 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 type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' @@ -37,14 +37,8 @@ export type StructuredAgentSessionReadRestoreDeps = { ) => Promise | void } -/** - * One session's share of the restart restore, and the whole of an on-demand one. - * - * Startup maps this over every supported record; a surface asking for a session it cannot see - * calls it for one id. The CALLER decides which records are eligible — startup filters by - * `supportsRecord` before mapping, so an on-demand caller owes the same check. - */ -export async function restoreOneStructuredAgentSessionRead( +/** One session's share of the restart restore. Startup maps this over every supported record. */ +async function restoreOneStructuredAgentSessionRead( input: StructuredAgentSessionReadRestoreDeps, sessionId: string ): Promise { @@ -58,14 +52,13 @@ export async function restoreOneStructuredAgentSessionRead( ) } -/** The serialized half of the restore, for a caller already inside the session's serialize — a - * send replaying into a session this host has closed, which needs the journal and no child. */ -export async function restoreOneStructuredAgentSessionReadUnderSerialize( +/** The serialized half of the restore. */ +async function restoreOneStructuredAgentSessionReadUnderSerialize( input: Pick, sessionId: string ): Promise { if (input.hasSession(sessionId)) { - // A surface that took a hold mid-restore already attached this one. + // A read or a send mid-restore already opened this one. return } const opened = await restoreStructuredAgentSessionRead(input.openDeps, sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 0fa4ee8534d..d053caf006b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -1,21 +1,18 @@ // Restart offers are durable per-session records. Listing reserves nothing and removes only offers // the chat has provably moved past; an explicit action reserves records, and only a completed -// action removes them — or files what went wrong. +// action removes them — or files what went wrong. Starting the chat's agent again withdraws them. import { randomUUID } from 'node:crypto' +import { + AgentSessionRefusalError, + agentSessionRefusalFromReference +} from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionResumeMarker, AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionSendResult -} from '../../../shared/agent-session-wire' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' import { @@ -34,33 +31,21 @@ import { type StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' import { - continueStructuredAgentSessionAfterRestart, - noteRestartReattachFailed, restartContinuationDeps, + startStructuredAgentSessionContinuation, type StructuredAgentSessionContinuationHost, type StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' +import { restartContinuationId } from './structured-agent-session-restart-continuation-envelope' +import { + createStructuredAgentSessionRestartOfferWithdrawal, + type StructuredAgentSessionRestartOfferSession +} from './structured-agent-session-restart-offer-withdrawal' +import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -type LiveSession = { journal: AgentSessionJournal; child: { fence: number } | null } - -export type StructuredAgentSessionRestartResumeSurfaces = { - revealSession: (sessionId: string) => Promise<{ readable: boolean }> - hold: (sessionId: string, holderId: string) => Promise - release: (sessionId: string, holderId: string) => void - send: (input: { - envelope: AgentSessionMutationEnvelope - body: AgentJournalMessageItem - beforeRun?: () => void - }) => Promise> - awaitSendSettlement: ( - sessionId: string, - clientMessageId: string - ) => Promise<{ value: AgentSessionSendResult } | undefined> - onNoteFailed: (sessionId: string, error: unknown) => void - now: () => number -} +type LiveSession = StructuredAgentSessionRestartOfferSession export type StructuredAgentSessionRestartResume = { /** Teardown: begin, then per session a snapshot right before its child stops and a confirmation @@ -72,10 +57,6 @@ export type StructuredAgentSessionRestartResume = { list: () => Promise /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ listFailures: () => Promise - resume: ( - sessionIds: readonly string[] | undefined, - owner: string - ) => Promise continueAfterRestart: ( sessionIds: readonly string[] | undefined, owner: string @@ -87,6 +68,8 @@ export type StructuredAgentSessionRestartResume = { }> /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ dismiss: (sessionIds?: readonly string[]) => Promise + /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ + onAgentStarted: (sessionId: string) => void } export function createStructuredAgentSessionRestartResume( @@ -101,11 +84,6 @@ export function createStructuredAgentSessionRestartResume( const admission = new StructuredAgentSessionResumeAdmission() const enqueueRecoveryOperation = createStructuredAgentSessionRestartOperationQueue() - const derive = createStructuredAgentSessionRestartCandidateReader({ - sessions, - getRecord: deps.store.getRecord, - adapter: deps.adapter - }) const witnesses = createStructuredAgentSessionRestartWitnesses({ sessions, getRecord: deps.store.getRecord, @@ -115,15 +93,24 @@ export function createStructuredAgentSessionRestartResume( now: surfaces.now, enqueue: enqueueRecoveryOperation }) + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + sessions, + ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), + now: surfaces.now, + enqueue: enqueueRecoveryOperation + }) + const derive = createStructuredAgentSessionRestartCandidateReader({ + sessions, + getRecord: deps.store.getRecord, + adapter: deps.adapter, + movedOn: withdrawal.movedOn + }) const failures = createStructuredAgentSessionRestartFailureLedger({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), - sessions, - reveal: async (sessionId) => { - await surfaces.revealSession(sessionId).catch(() => null) - }, getRecord: deps.store.getRecord, adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, + reveal: (markers) => revealMarkers(markers), now: surfaces.now, enqueue: enqueueRecoveryOperation }) @@ -154,22 +141,19 @@ export function createStructuredAgentSessionRestartResume( ...surfaces, sessions, conversationFence: (sessionId) => - sessions.has(sessionId) + deps.store.getRecord(sessionId) ? structuredAgentSessionConversationFence(deps.store, sessionId) : null, - stillResumable: (marker, options) => - derive([marker], 'may-be-held', options).candidates.length === 1 + stillResumable: (marker) => derive([marker], 'may-be-held').candidates.length === 1 } + /** One explicit action: reserve the offers, then continue each through `continueOne`, a few at + * a time. The runner counts a chat as done once `continueOne` returns. */ const run = async ( sessionIds: readonly string[] | undefined, owner: string, - afterAcquire?: (marker: AgentSessionResumeMarker) => Promise, - settlement: Omit[2], 'candidates' | 'attempts'> = { - failureAfterResume: () => null, - failureReason: () => 'agent_session_resume_refused' - } - ): Promise => { + continueOne: (marker: AgentSessionResumeMarker, continuationId: string) => Promise + ) => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation // lane below also drains a publication already in flight before completion. witnesses.clear() @@ -180,25 +164,26 @@ export function createStructuredAgentSessionRestartResume( retireSuperseded(derived.superseded) const eligible = derived.candidates.filter((candidate) => requested.has(candidate.sessionId)) if (eligible.length === 0) { - return [] + return null } const operationId = randomUUID() + const actionAt = surfaces.now() + // Tagged with its offer, so a rejected one is told apart for as long as the offer lasts. + const continuationFor = (marker: AgentSessionResumeMarker) => + restartContinuationId(marker, operationId, actionAt) const reserved = (await enqueueRecoveryOperation( () => deps.recoveryCapsule?.beginResume( eligible.map((candidate) => candidate.sessionId), operationId, - surfaces.now() + actionAt ) ?? Promise.resolve([]) )) ?? [] const markersBySession = new Map(reserved.map((marker) => [marker.sessionId, marker])) const candidates = derive(reserved, 'may-be-held').candidates - const attempts = failures.attempts(markersBySession) - - let outcomes: StructuredAgentSessionResumeOutcome[] try { - outcomes = await resumeStructuredAgentSessionsFromRestart( + const outcomes = await resumeStructuredAgentSessionsFromRestart( { admission, consumeMarker: async (sessionId) => { @@ -206,26 +191,16 @@ export function createStructuredAgentSessionRestartResume( return marker !== undefined && derive([marker], 'may-be-held').candidates.length === 1 }, resume: async (sessionId) => { - const holder = `restart-resume:${sessionId}` - try { - await surfaces.hold(sessionId, holder).catch(async (error: unknown) => { - // The reattach failure is filed like any other, so the chat must say so too. - await noteRestartReattachFailed(continuationHost, sessionId) - throw error - }) - const marker = markersBySession.get(sessionId) - if (marker) { - await afterAcquire?.(marker) - } - } finally { - attempts.observe(sessionId) - surfaces.release(sessionId, holder) + const marker = markersBySession.get(sessionId) + if (marker) { + await continueOne(marker, continuationFor(marker)) } } }, candidates, owner ) + return { operationId, outcomes, candidates, markers: markersBySession } } catch (error) { if (deps.recoveryCapsule) { await enqueueRecoveryOperation(() => @@ -236,8 +211,6 @@ export function createStructuredAgentSessionRestartResume( } throw error } - await failures.settle(operationId, outcomes, { candidates, attempts, ...settlement }) - return outcomes } const continueAfterRestart = async ( @@ -250,34 +223,50 @@ export function createStructuredAgentSessionRestartResume( failed?: StructuredAgentSessionResumeFailure[] }> => { const continued: StructuredAgentSessionContinuationOutcome[] = [] - const continuationFor = (sessionId: string) => - continued.find((outcome) => outcome.sessionId === sessionId) - const resumed = await run( - sessionIds, - owner, - async (marker) => { - continued.push( - await continueStructuredAgentSessionAfterRestart( - restartContinuationDeps(continuationHost, marker), - marker.sessionId, - marker - ) - ) - }, - { + const verdicts: Promise[] = [] + // A chat holds its slot until its agent took the continuation or its start failed, so a batch + // never starts more agents at once than the runner allows; the provider's answer comes after. + const action = await run(sessionIds, owner, async (marker, continuationId) => { + const started = await startStructuredAgentSessionContinuation( + restartContinuationDeps(continuationHost, marker), + marker.sessionId, + marker, + continuationId + ) + if ('done' in started) { + continued.push(started.done) + const { outcome, reason, refusal } = started.done + if (outcome === 'refused') { + // Thrown as a refusal so the filed failure keeps its details beside the code. + throw refusal + ? new AgentSessionRefusalError(agentSessionRefusalFromReference(refusal, refusal.code)) + : new Error(reason ?? 'agent_session_continuation_refused') + } + return + } + verdicts.push(started.verdict().then((outcome) => void continued.push(outcome))) + }) + await Promise.all(verdicts) + const resumed = action?.outcomes ?? [] + if (action) { + await failures.settle(action.operationId, resumed, { + candidates: action.candidates, + markers: action.markers, failureAfterResume: (sessionId) => { - const outcome = continuationFor(sessionId) - // Reattached but never asked to continue: nothing confirms the agent carried on. - return outcome ? continuationFailureOutcome(outcome.outcome) : 'unconfirmed' + const outcome = continued.find((entry) => entry.sessionId === sessionId) + return outcome ? continuationFailureOutcome(outcome.outcome) : null }, failureReason: (sessionId) => { - const outcome = continuationFor(sessionId) + const outcome = continued.find((entry) => entry.sessionId === sessionId) return outcome?.reason ?? outcome?.outcome ?? 'agent_session_continuation_unknown' } - } - ) + }) + } for (const outcome of resumed) { - if (outcome.outcome !== 'resumed') { + if ( + outcome.outcome !== 'resumed' && + !continued.some((entry) => entry.sessionId === outcome.sessionId) + ) { continued.push({ sessionId: outcome.sessionId, outcome: 'refused', @@ -311,7 +300,7 @@ export function createStructuredAgentSessionRestartResume( // Do not let a teardown witness already captured in this host republish after explicit // dismissal. A later capture is a new interruption and may create a fresh offer normally. dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), - resume: (sessionIds, owner) => run(sessionIds, owner), - continueAfterRestart + continueAfterRestart, + onAgentStarted: withdrawal.onAgentStarted } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts index 33a07e80335..a298e23f454 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts @@ -1,14 +1,17 @@ // Delivering the explicit restart action: the one path that turns a resumable candidate back into -// a live agent. +// a working agent. // // The manual "Resume" button and the automatic setting both land here, so the two can never drift // into different eligibility or different double-fire protection. // -// Resume itself acquires a provider child, not a new send. The first resume-capable hold on a -// childless session re-acquires the provider's own conversation — Claude's `resume` by session id, -// Codex's thread id — which is native continuation. Nothing re-sends the user's prompt: that is -// what makes an agent redo work it already finished. +// A resume is the continuation send. Its delivery starts the provider on its own conversation — +// Claude's `resume` by session id, Codex's thread id — which is native continuation. Nothing +// re-sends the user's prompt: that is what makes an agent redo work it already finished. +import { + isAgentSessionRefusalError, + type AgentSessionAnyRefusalDetails +} from '../../../shared/agent-session-wire-refusals' import { forEachWithConcurrency } from '../../../shared/map-with-concurrency' import type { StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' @@ -27,6 +30,8 @@ export type StructuredAgentSessionResumeOutcome = { outcome: 'resumed' | 'refused' /** Refusal code; `agent_session_resume_already_in_progress` names the live owner in `owner`. */ reason?: string + /** A thrown refusal's details, kept apart so `reason` stays the code readers match. */ + details?: AgentSessionAnyRefusalDetails owner?: string } @@ -52,7 +57,7 @@ function resumeAdmissionOwner(error: unknown): string | null { * One resume per session at a time, whoever is asking. * * Two surfaces can reach for the same chat at once — the banner's "Resume all" and a user clicking - * one row — and both would otherwise take a hold, race the acquisition, and leave the loser's + * one row — and both would otherwise send the continuation twice, and leave the loser's * refusal looking like a real failure. The second caller is told who holds it instead. */ export class StructuredAgentSessionResumeAdmission { @@ -80,7 +85,7 @@ export type StructuredAgentSessionResumeRunnerDeps = { admission: StructuredAgentSessionResumeAdmission /** Validates this action's durable reservation. False means the candidate is no longer eligible. */ consumeMarker: (sessionId: string) => Promise - /** Acquires the provider child for the reserved session. */ + /** Continues the reserved session; resolves once its agent took the message or refused it. */ resume: (sessionId: string) => Promise concurrency?: number } @@ -123,10 +128,12 @@ async function resumeOne( } catch (error) { const reason = error instanceof Error ? error.message : String(error) const owner = resumeAdmissionOwner(error) + const details = isAgentSessionRefusalError(error) ? error.refusal.details : undefined return { sessionId, outcome: 'refused', reason, + ...(details ? { details } : {}), ...(owner === null ? {} : { owner }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 245fbcf4bbf..3c91a9732b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -7,12 +7,12 @@ // restored thread), and every reading of those rewrites as "the work is done" dropped chats that // were owed a resume. // -// An offer ends only by the user's own actions. The ones this predicate can see — a newer message -// of theirs in that chat, or the conversation forked — are reported back as `superseded` so the -// caller DELETES the record rather than filtering it forever. What remains are structural checks -// that are not about work at all: the record still exists and this build supports it, and the -// lease is free. +// An offer ends when the chat moves on after the restart — another message accepted, or its agent +// started — or when the conversation forked. Both are reported back as `superseded` so the caller +// DELETES the record rather than filtering it forever. What remains are structural checks that are not about work at all: the record still +// exists and this build supports it, and the lease is free. +import type { AgentSessionAnyRefusalDetails } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionProviderHandleChainHead, @@ -57,6 +57,8 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa outcome: AgentSessionResumeFailureOutcome /** The host's or provider's refusal code, verbatim, so it can be quoted in a report. */ reason: string + /** The refusal's details beside its code in `reason`; absent on older records and non-refusals. */ + details?: AgentSessionAnyRefusalDetails /** Whether naming it in an action would run it again: whether it is still an offer. A * continuation the chat already holds, or the user having moved on, makes a retry a no-op no * matter what the reason says. */ @@ -65,8 +67,8 @@ export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCa export type StructuredAgentSessionResumableSet = { candidates: StructuredAgentSessionResumeCandidate[] - /** Markers the chat has provably moved past — a newer user message, or a forked conversation. - * Every ending deletes: the caller retires these rather than re-filtering them forever. */ + /** Markers the chat has provably moved past, or whose conversation forked. Every ending deletes: + * the caller retires these rather than re-filtering them forever. */ superseded: AgentSessionResumeMarker[] } @@ -75,8 +77,8 @@ export type StructuredAgentSessionResumeSetInput = { getRecord: (sessionId: string) => AgentSessionRecord | null supportsRecord: (record: AgentSessionRecord) => boolean latestPrompt: (sessionId: string) => string - /** Undefined when the chat's journal is not readable here, which decides nothing. */ - latestUserItemId: (sessionId: string) => string | null | undefined + /** Whether the chat moved on since the offer was taken; false when its journal is not open here. */ + movedOn: (marker: AgentSessionResumeMarker) => boolean /** * Whether the lease must be free. * @@ -103,7 +105,7 @@ export function structuredAgentSessionResumableSet( continue } // A conversation that FORKED since teardown is not the one we marked, and can never be again: - // deleted like a newer message, so it cannot sit unseen forever. Compared by identity root, + // deleted, so it cannot sit unseen forever. Compared by identity root, // because a resume legitimately advances Claude's leaf and that is not a fork. const head = agentSessionProviderHandleChainHead(record.providerHandleChain) if (!head) { @@ -113,10 +115,7 @@ export function structuredAgentSessionResumableSet( superseded.push(marker) continue } - // The user moving on is the one thing that withdraws the offer. Anything the provider does on - // its own after reattaching — a turn it opens, a prompt, restated rows — is not. - const latestUserItemId = input.latestUserItemId(marker.sessionId) - if (latestUserItemId !== undefined && latestUserItemId !== marker.latestUserItemId) { + if (input.movedOn(marker)) { superseded.push(marker) continue } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index 68823c078dc..e359e31157b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -203,13 +203,12 @@ export function resumableSet(input: { markers: AgentSessionResumeMarker[] items?: AgentJournalRenderItem[] chain?: AgentSessionRecord['providerHandleChain'] - latestUserItemId?: string | null }) { return structuredAgentSessionResumableSet({ markers: input.markers, getRecord: () => record(input.chain === undefined ? {} : { chain: input.chain }), supportsRecord: () => true, latestPrompt: () => 'fix the auth bug', - latestUserItemId: () => input.latestUserItemId ?? null + movedOn: () => false }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 66461a0c873..4004502e408 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -12,9 +12,27 @@ import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import { MAX_TIMER_DELAY_MS } from '../../../shared/timer-delay' -import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-host' import type { SendSettlementWaitOptions } from './structured-agent-session-send-settlement' +export type StructuredAgentSessionRestartResumeSurfaces = { + revealSession: (sessionId: string) => Promise<{ readable: boolean }> + send: (input: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem + beforeRun?: () => void + }) => Promise> + awaitSendSettlement: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> + awaitSendHandedOver: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> + onNoteFailed: (sessionId: string, error: unknown) => void + now: () => number +} + /** The caller key the continuation sends under, so its writes are attributable to Orca itself. */ export const STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER = 'trusted-local:restart-continuation' @@ -23,8 +41,6 @@ export const STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER = * dependency, and nothing outside this list is reachable from here. */ type RestartResumeHostBindings = { revealSession: (sessionId: string) => Promise<{ readable: boolean }> - hold: (sessionId: string, holderId: string) => Promise - release: (sessionId: string, holderId: string) => void send: ( caller: { callerKey: string }, params: { @@ -47,14 +63,19 @@ export function structuredAgentSessionRestartResumeSurfaces( ): StructuredAgentSessionRestartResumeSurfaces { return { revealSession: host.revealSession, - hold: host.hold, - release: host.release, send: (params) => host.send({ callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, params), // Accepted like any send, so its verdict is its delivery, however long the start takes; the // wait ends when the submission settles or the session closes. awaitSendSettlement: (sessionId, clientMessageId) => host.waitForSendSettlement(sessionId, clientMessageId, { budgetMs: MAX_TIMER_DELAY_MS }), + // How long a restart batch holds a chat's slot: until the agent took the message or its start + // failed. Undefined — the session closed, or too many waited — frees the slot too. + awaitSendHandedOver: (sessionId, clientMessageId) => + host.waitForSendSettlement(sessionId, clientMessageId, { + until: 'handed-over', + budgetMs: MAX_TIMER_DELAY_MS + }), onNoteFailed: () => console.warn('[structured-agent-session] restart continuation attribution failed'), now diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 5dc939d7e66..efcd70345a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -17,6 +17,7 @@ import { structuredAgentSessionWorkingAtStop } from './structured-agent-session- import { CLAUDE_ROOT, claudeRecord, + EPOCH, HANDLE_ROOT, journal, TEARDOWN_CURRENT, @@ -64,6 +65,7 @@ describe('deriving what was working at teardown', () => { recordedAt: NOW, trigger: 'quit', teardownId: TEARDOWN_CURRENT, + journalCursor: { epoch: EPOCH, sequence: 1 }, providerHandleRoot: HANDLE_ROOT, latestUserItemId: null, activity: { state: 'working', prompts: [], tasks: [] } @@ -451,7 +453,7 @@ describe('the resumable set', () => { getRecord: () => claudeRecord('5aed93d6-advanced-leaf'), supportsRecord: () => true, latestPrompt: () => '', - latestUserItemId: () => null + movedOn: () => false }) expect(candidates).toHaveLength(1) @@ -466,14 +468,14 @@ describe('the resumable set', () => { getRecord: () => claudeRecord(null, 'prov-session-2'), supportsRecord: () => true, latestPrompt: () => '', - latestUserItemId: () => null + movedOn: () => false }) expect(set.candidates).toEqual([]) expect(set.superseded).toEqual([forked]) }) - // An offer has no expiry: it ends only by the user's own actions, however old it is. + // An offer has no expiry, however old it is. it('still offers a months-old marker', () => { const monthsAgo = NOW - 90 * 24 * 60 * 60 * 1000 expect(resumableSet({ markers: [marker({ recordedAt: monthsAgo })] }).candidates).toHaveLength( @@ -481,16 +483,6 @@ describe('the resumable set', () => { ) }) - // The user moving on is what withdraws an offer — and it is reported for DELETION, not merely - // filtered, so the record does not have to be re-filtered on every read forever. - it('withdraws and reports for deletion once the user has sent a newer message', () => { - const withdrawn = marker() - const set = resumableSet({ markers: [withdrawn], latestUserItemId: 'user-newer' }) - - expect(set.candidates).toEqual([]) - expect(set.superseded).toEqual([withdrawn]) - }) - // Structural refusals are NOT endings: a record this host cannot see right now must not delete // a durable offer the user still owns. it('does not report a structurally refused marker for deletion', () => { 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 cc40d483187..bf740ac405b 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 @@ -30,7 +30,7 @@ it.each(['beginTeardown', 'captureBeforeStop', 'recordMarkers'] as const)( ) expect(warning.mock.calls.flat().map(String).join(' ')).not.toContain(failure.message) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(() => host.journalSnapshot(SESSION)).toThrow('agent_session_ownership_unknown') + await expect(host.journalSnapshot(SESSION)).rejects.toThrow('agent_session_ownership_unknown') } finally { operation.mockRestore() warning.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts index 7ba1c716769..49a116ec7f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.test.ts @@ -3,8 +3,8 @@ * * The reveal path is the only way an Agent Session History row reaches a chat whose tab this * process never published — a chat closed cleanly, or one this process has not opened since - * launch. It is exercised here through the readable restorer rather than the whole host, because - * what it must get right is which records it accepts and what it does when the journal is gone. + * launch. It opens the conversation through the host's accessor and starts no agent; what it must + * get right is which records it accepts and what it answers when the journal cannot be opened. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,8 +13,6 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' -import * as readRestore from './structured-agent-session-read-restore' import * as providerSupport from './structured-agent-session-provider-support' import { revealStructuredAgentSession } from './structured-agent-session-reveal' @@ -35,122 +33,10 @@ function recordFor(provider: 'claude' | 'codex', sessionId: string): AgentSessio } } -function harness( - records: AgentSessionRecord[], - options: { supports?: (record: AgentSessionRecord) => boolean } = {} -) { - const live = new Map() - const serializedIds: string[] = [] - const serialize = (sessionId: string, task: () => Promise): Promise => { - serializedIds.push(sessionId) - return task() - } - const restorer = new StructuredAgentSessionReadableRestorer({ - openDeps: { - store: { - getRecord: (sessionId: string) => records.find((r) => r.sessionId === sessionId) ?? null, - listRecords: () => records - }, - journalRoot: '/journals', - adapter: {} - }, - supportsRecord: options.supports ?? (() => true), - reconcile: async () => null, - resolveRecovery: async () => undefined, - serialize, - hasSession: (sessionId) => live.has(sessionId), - onReadable: (sessionId, restored) => { - live.set(sessionId, restored) - } - }) - return { restorer, live, serializedIds } -} - -const readable = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restorer only indexes the session it is handed; no member of it is read here. - session: { journal: {}, params: {}, fence: 1 } as never, - reset: null -} - afterEach(() => { vi.restoreAllMocks() }) -describe('revealing one structured session on demand', () => { - beforeEach(() => { - vi.spyOn(readRestore, 'restoreStructuredAgentSessionRead').mockResolvedValue(readable) - }) - - it.each(['claude', 'codex'] as const)('restores a persisted %s chat', async (provider) => { - const sessionId = `session-${provider}` - const { restorer, live } = harness([recordFor(provider, sessionId)]) - - await expect(restorer.restoreOne(sessionId)).resolves.toBe(true) - expect(live.has(sessionId)).toBe(true) - }) - - it('does not need the startup sweep to have run, or run it', async () => { - // The whole point: `restore()` is latched to once per process, and a surface asking later must - // not be answered from that latch — nor trip it, which would skip every other record. - const records = [recordFor('codex', 'session-asked'), recordFor('claude', 'session-untouched')] - const { restorer, live } = harness(records) - - await restorer.restoreOne('session-asked') - - expect(live.has('session-asked')).toBe(true) - expect(live.has('session-untouched')).toBe(false) - }) - - it('serializes against the session it restores', async () => { - const { restorer, serializedIds } = harness([recordFor('claude', 'session-serialized')]) - - await restorer.restoreOne('session-serialized') - - // Ordering against close/eviction is the task queue's job, so the restore must be inside it. - expect(serializedIds).toEqual(['session-serialized']) - }) - - it('returns the live session instead of restoring over it', async () => { - const { restorer, live } = harness([recordFor('codex', 'session-live')]) - live.set('session-live', readable) - - await expect(restorer.restoreOne('session-live')).resolves.toBe(true) - expect(readRestore.restoreStructuredAgentSessionRead).not.toHaveBeenCalled() - }) - - it('refuses a record no adapter supports', async () => { - const { restorer } = harness([recordFor('codex', 'session-unsupported')], { - supports: () => false - }) - - await expect(restorer.restoreOne('session-unsupported')).resolves.toBe(false) - expect(readRestore.restoreStructuredAgentSessionRead).not.toHaveBeenCalled() - }) - - it('refuses a session this host holds no record for', async () => { - const { restorer } = harness([]) - - await expect(restorer.restoreOne('session-absent')).resolves.toBe(false) - }) -}) - -describe('a record whose journal cannot be read', () => { - it('reports not-readable without throwing, for either provider', async () => { - // A chat whose journal predates the SQLite store restores to nothing here. That is not a - // refusal: attach still recovers it, so the caller publishes the tab and lets the pane's hold - // finish the job. Throwing, or reporting success, would both be wrong. - vi.spyOn(readRestore, 'restoreStructuredAgentSessionRead').mockResolvedValue(null) - const { restorer, live } = harness([ - recordFor('claude', 'session-no-journal-claude'), - recordFor('codex', 'session-no-journal-codex') - ]) - - await expect(restorer.restoreOne('session-no-journal-claude')).resolves.toBe(false) - await expect(restorer.restoreOne('session-no-journal-codex')).resolves.toBe(false) - expect(live.size).toBe(0) - }) -}) - describe('the host answer a client acts on', () => { beforeEach(() => { // Eligibility is the router's call; these cases are about what the answer carries. @@ -162,39 +48,45 @@ describe('the host answer a client acts on', () => { return { ...base, location: { ...base.location, workspaceId } } } - it("answers with the record's own workspace and provider, never a caller's", async () => { - // The security property: a client sends only a session id, so the tab cannot be aimed at - // another workspace by asking for one. - const stored = record('claude', 'workspace-from-record') + it.each(['claude', 'codex'] as const)( + "answers a %s chat with the record's own workspace and provider, never a caller's", + async (provider) => { + // The security property: a client sends only a session id, so the tab cannot be aimed at + // another workspace by asking for one. + const stored = record(provider, 'workspace-from-record') + const open = vi.fn(async () => undefined) - await expect( - revealStructuredAgentSession( - { store: { getRecord: () => stored } as never, adapter: {} as never }, - 'session-answered', - () => true, - async () => true - ) - ).resolves.toEqual({ - sessionId: 'session-answered', - workspaceId: 'workspace-from-record', - agent: 'claude', - readable: true - }) - }) + await expect( + revealStructuredAgentSession( + { store: { getRecord: () => stored } as never, adapter: {} as never }, + 'session-answered', + open + ) + ).resolves.toEqual({ + sessionId: 'session-answered', + workspaceId: 'workspace-from-record', + agent: provider, + readable: true + }) + expect(open).toHaveBeenCalledExactlyOnceWith('session-answered') + } + ) - it('refuses a session this host holds no record for', async () => { + it('refuses a session this host holds no record for, opening nothing', async () => { + const open = vi.fn(async () => undefined) await expect( revealStructuredAgentSession( { store: { getRecord: () => null } as never, adapter: {} as never }, 'session-absent', - () => false, - async () => false + open ) ).rejects.toThrow('agent_session_identity_required') + expect(open).not.toHaveBeenCalled() }) it('refuses a record no adapter of this host supports', async () => { vi.mocked(providerSupport.adapterSupportsRecord).mockReturnValue(false) + const open = vi.fn(async () => undefined) await expect( revealStructuredAgentSession( @@ -203,14 +95,14 @@ describe('the host answer a client acts on', () => { adapter: {} as never }, 'session-answered', - () => false, - async () => false + open ) ).rejects.toThrow('structured_agent_session_unsupported') + expect(open).not.toHaveBeenCalled() }) - it('answers not-readable without refusing when the journal could not be restored', async () => { - // The tab is still worth publishing: attach recovers what read restore cannot. + it('answers not-readable without refusing when the journal could not be opened', async () => { + // The tab is still worth publishing: the chat shows the failure and keeps retrying the read. await expect( revealStructuredAgentSession( { @@ -218,26 +110,10 @@ describe('the host answer a client acts on', () => { adapter: {} as never }, 'session-answered', - () => false, - async () => false + async () => { + throw new Error('journal unreadable') + } ) ).resolves.toMatchObject({ readable: false, agent: 'codex' }) }) - - it('does not restore over a session that is already live', async () => { - const restoreReadable = vi.fn(async () => true) - - await expect( - revealStructuredAgentSession( - { - store: { getRecord: () => record('codex', 'workspace-1') } as never, - adapter: {} as never - }, - 'session-answered', - () => true, - restoreReadable - ) - ).resolves.toMatchObject({ readable: true }) - expect(restoreReadable).not.toHaveBeenCalled() - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index c6ea9c1f83b..2a1899ab1cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -6,11 +6,11 @@ // launch — is reachable in Agent Session History by id and by nothing else. This is the lookup that // turns that id back into something a client can publish. // -// It is deliberately the whole of what reveal does on the host. It takes no hold: a provider child -// exists because a surface asked for one, and the chat pane asks when it binds. And a journal it -// cannot read is not a refusal — a chat whose journal predates the SQLite store restores to nothing -// here, yet attach still recovers it, so the tab is worth publishing either way. +// It is deliberately the whole of what reveal does on the host. It starts no provider child: only +// a send does. And a journal it cannot open is not a refusal — the chat shows that failure with a +// Retry, so the tab is worth publishing either way. +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' @@ -19,24 +19,28 @@ import type { StructuredAgentSessionReveal } from './structured-agent-session-host-types' -/** Throws its refusal as the code itself, matching `resumeHeldStructuredAgentSession`. */ +/** Throws its refusal as the code itself. */ export async function revealStructuredAgentSession( deps: Pick, sessionId: string, - hasSession: (sessionId: string) => boolean, - restoreReadable: (sessionId: string) => Promise + openConversation: (sessionId: string) => Promise ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } if (!adapterSupportsRecord(deps.adapter, record)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } // Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a - // contested or reconciling chat still reveals and the hold that follows adjudicates it. Refusing + // contested or reconciling chat still reveals and the send that follows adjudicates it. Refusing // here would hide the one view of a session a user needs when its ownership is in doubt. - const readable = hasSession(sessionId) || (await restoreReadable(sessionId)) + const readable = await openConversation(sessionId).then( + () => true, + () => false + ) return { sessionId, // From the record, never from a caller: a client that knows only a session id must not be able @@ -47,12 +51,7 @@ export async function revealStructuredAgentSession( } } -/** - * The host's whole readable-restore surface: the startup sweep and the on-demand reveal. - * - * Bundled the way the lifetime collaborators are, because the two share the restorer - * and differ only in who is asking — startup, once, for everything; a surface, later, for one. - */ +/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< @@ -61,7 +60,6 @@ export function createStructuredAgentSessionHostRestore( > ): { restoreReadableSessions: (sessionIds?: readonly string[]) => Promise - revealSession: (sessionId: string) => Promise } { const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, @@ -70,10 +68,6 @@ export function createStructuredAgentSessionHostRestore( }) const gate = new StructuredAgentSessionRestartRestoreGate() return { - restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)), - revealSession: (sessionId) => - revealStructuredAgentSession(deps, sessionId, wiring.hasSession, (id) => - restorer.restoreOne(id) - ) + restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)) } } 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 new file mode 100644 index 00000000000..a596cc5c9ed --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -0,0 +1,225 @@ +// 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. + +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 { 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 { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const caller = { callerKey: 'desktop' } +const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } + +let directory: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink +let acquires = 0 +const rewind = vi.fn>() +const recoverRewind = vi.fn>() +const rewindSupport = vi.fn>() +const dispatch = vi.fn() + +function adapter(): StructuredAgentSessionAdapter { + return { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires += 1 + sink = input.events! + return { + process: { + hostId: 'local', + pid: 4000 + acquires, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires}`, + link: { + linkId: `link-${acquires}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires === 1 ? 'created' : 'resumed', + handle: { provider: 'codex', threadId: THREAD } + } + } + }, + dispatch, + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport, + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true + } +} + +function openHost(): StructuredAgentSessionHost { + return new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: directory, + claimKeyId: 'key', + now: () => HOST_TEST_NOW, + probeOwner: async () => ({ outcome: 'exit-observed' }), + idleSweep: { intervalMs: 3_600_000 } + }) +} + +beforeEach(async () => { + resetHostTestOperationIds() + acquires = 0 + rewind.mockReset() + recoverRewind.mockReset().mockResolvedValue({ + ok: true, + items: [{ identity: KEPT, body: hostTestMessage('verified history') }] + }) + rewindSupport.mockReset().mockReturnValue({ supported: true }) + dispatch.mockReset().mockImplementation(async (input): Promise => ({ + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: THREAD, + turnId: input.clientMessageId, + ordinal: 1 + } + })) + directory = await mkdtemp(join(tmpdir(), 'orca-rewind-rest-')) + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + host = openHost() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(directory, { recursive: true, force: true }) +}) + +function fence(): number { + return store.getRecord(SESSION)!.lease.runtimeFence +} + +function rewindParams(itemId: string, expectedEpoch: string) { + return { + itemId, + expectedEpoch, + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.rewind', + sessionId: SESSION, + fields: { itemId, expectedEpoch } + }) + } + } +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { + body, + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + } + } +} + +/** A chat whose rewind the provider applied, whose Orca side never finished, reopened at rest. */ +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')) + await host.flushStreamedEvents(SESSION) + rewind.mockImplementation(async (input) => { + await input.onReverted?.() + throw new Error('history unavailable') + }) + const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch + await expect(host.rewind(caller, rewindParams(agentJournalItemKey(drop), epoch))).rejects.toThrow( + 'history unavailable' + ) + expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + host = openHost() +} + +describe('an interrupted Codex rewind on a chat at rest (R16)', () => { + it('is settled by the start a send makes, and the message is delivered after it', async () => { + await interruptedRewindAtRest() + expect(host.hasSession(SESSION)).toBe(false) + const before = acquires + + const sent = await host.send(caller, sendParams('after the rewind')) + expect(sent).toMatchObject({ ok: true }) + expect(acquires - before).toBe(1) + expect(recoverRewind).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('completed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + const snapshot = await host.journalSnapshot(SESSION) + const texts = snapshot.items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + // The recovered history, then the new message — nothing the rewind dropped, nothing lost. + expect(texts).toEqual(['verified history', 'after the rewind']) + expect(snapshot.submissions.at(-1)?.dispatchState).toBe('accepted') + }) +}) + +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')) + 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' + }) + host = openHost() + const before = acquires + rewindSupport.mockReturnValue({ supported: false, reason: 'history-not-paginated' }) + + const result = await host.rewind(caller, rewindParams(agentJournalItemKey(KEPT), epoch)) + expect(acquires - before).toBe(1) + expect(result).toMatchObject({ ok: false, refusal: { rewindReason: 'history-not-paginated' } }) + expect(rewind).not.toHaveBeenCalled() + }) +}) 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 32b05b08077..a8ded60e6ff 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 @@ -140,11 +140,11 @@ async function seed(acceptedSubmissions = false) { }) ).toMatchObject({ ok: true }) // Accepted into the conversation first; the delivery loop hands it over after. - await vi.waitFor(() => + await vi.waitFor(async () => expect( - host - .journalSnapshot(HOST_TEST_SESSION) - .submissions.find((entry) => entry.clientMessageId === clientOperationId)?.dispatchState + (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.dispatchState ).toBe('accepted') ) if (i === 1) { @@ -157,10 +157,8 @@ async function seed(acceptedSubmissions = false) { await host.flushStreamedEvents(HOST_TEST_SESSION) return selectedItemId } -function params( - itemId: string, - expectedEpoch = host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch -) { +async function params(itemId: string, epoch?: string) { + const expectedEpoch = epoch ?? (await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch return { itemId, expectedEpoch, @@ -181,14 +179,14 @@ describe('host rewind', () => { it('resolves accepted codex user submissions to provider targets', async () => { const target = await seed(true) expect(target.startsWith('orca:')).toBe(true) - expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' })) }) it('finishes a durable provider success on reattach without repeating the provider mutation', async () => { const target = await seed() - const request = params(target) + const request = await params(target) const replace = vi .spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') .mockRejectedValueOnce(new Error('disk failed')) @@ -197,7 +195,7 @@ describe('host rewind', () => { replace.mockRestore() const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) @@ -205,13 +203,13 @@ describe('host rewind', () => { it('retries complete hydration after native acknowledgement without committing partial history', async () => { const target = await seed() - const before = host.journalSnapshot(HOST_TEST_SESSION) + const before = await host.journalSnapshot(HOST_TEST_SESSION) rewind.mockImplementation(async (input) => { await input.onReverted?.() throw new Error('history unavailable') }) - await expect(host.rewind(caller, params(target))).rejects.toThrow('history unavailable') - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + 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 @@ -230,8 +228,8 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) - expect(host.journalSnapshot(HOST_TEST_SESSION).items[0]?.body).toEqual( + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items[0]?.body).toEqual( hostTestMessage('verified history') ) expect(recoverRewind).toHaveBeenCalledTimes(2) @@ -246,9 +244,9 @@ describe('host rewind', () => { finish = () => resolve({ ok: true }) }) ) - const first = host.rewind(caller, params(target)) - const second = host.rewind(caller, params(target)) - await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + const first = host.rewind(caller, await params(target)) + const second = host.rewind(caller, await params(target)) + await vi.waitFor(async () => expect(finish).toBeTypeOf('function')) finish() expect(await first).toMatchObject({ ok: true }) expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) @@ -256,11 +254,13 @@ describe('host rewind', () => { }) it('replaces the epoch with the retained prefix and replays without another provider call', async () => { const target = await seed() - const request = params(target) + const request = await params(target) const result = await host.rewind(caller, request) expect(result).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1) - expect(host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch).not.toBe(request.expectedEpoch) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).cursor.epoch).not.toBe( + request.expectedEpoch + ) expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) }) @@ -270,7 +270,7 @@ describe('host rewind', () => { { provider: 'orca', clientMessageId: 'active' }, { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } } ) - expect(await host.rewind(caller, params(target))).toMatchObject({ + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: false, refusal: { rewindReason: 'busy' } }) @@ -278,11 +278,11 @@ describe('host rewind', () => { }) it('refuses stale epochs and targets from another provider', async () => { const target = await seed() - expect(await host.rewind(caller, params(target, 'old-epoch'))).toMatchObject({ + expect(await host.rewind(caller, await params(target, 'old-epoch'))).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } }) - expect(await host.rewind(caller, params('claude:foreign'))).toMatchObject({ + expect(await host.rewind(caller, await params('claude:foreign'))).toMatchObject({ ok: false, refusal: { rewindReason: 'invalid-target' } }) @@ -290,18 +290,18 @@ describe('host rewind', () => { }) it('keeps a failed hydration epoch intact and blocks sends and duplicate rewind', async () => { const target = await seed() - const request = params(target) - const before = host.journalSnapshot(HOST_TEST_SESSION) + const request = await params(target) + const before = await host.journalSnapshot(HOST_TEST_SESSION) rewind.mockRejectedValue(new Error('hydration failed')) await expect(host.rewind(caller, request)).rejects.toThrow('hydration failed') - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(await host.rewind(caller, request)).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_unknown' } }) const body = hostTestMessage('new prompt') const envelope = { - ...params(target).envelope, + ...(await params(target)).envelope, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: HOST_TEST_SESSION, @@ -326,9 +326,9 @@ describe('host rewind', () => { it('clears an unapplied prepared rewind after observing the target still present', async () => { const target = await seed() - const before = host.journalSnapshot(HOST_TEST_SESSION) + const before = await host.journalSnapshot(HOST_TEST_SESSION) rewind.mockRejectedValueOnce(new Error('read failed before revert')) - await expect(host.rewind(caller, params(target))).rejects.toThrow('read failed') + await expect(host.rewind(caller, await params(target))).rejects.toThrow('read failed') recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) expect( await host.attach( @@ -336,9 +336,9 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') - expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true }) + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) }) it('keeps host-stamped turn and goal rows through a Codex provider hydration', async () => { @@ -392,12 +392,17 @@ describe('host rewind', () => { return { ok: true, items } }) - expect(await host.rewind(caller, params(agentJournalItemKey(message('drop'))))).toMatchObject({ + expect( + await host.rewind(caller, await params(agentJournalItemKey(message('drop')))) + ).toMatchObject({ ok: true }) expect( - host.journalSnapshot(HOST_TEST_SESSION).items.map(({ itemId, body }) => ({ itemId, body })) + (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({ + itemId, + body + })) ).toEqual([ { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from provider') }, { itemId: agentJournalItemKey(goalRow), body: goalBody }, @@ -437,9 +442,9 @@ describe('host rewind', () => { throw new Error('lost after provider revert') }) - await expect(host.rewind(caller, params(agentJournalItemKey(message('drop'))))).rejects.toThrow( - 'lost after provider revert' - ) + await expect( + host.rewind(caller, await params(agentJournalItemKey(message('drop')))) + ).rejects.toThrow('lost after provider revert') recoverRewind.mockResolvedValueOnce({ ok: true, items: [{ identity: message('kept'), body: hostTestMessage('kept from recovery') }] @@ -452,7 +457,10 @@ describe('host rewind', () => { ).toMatchObject({ ok: true }) expect( - host.journalSnapshot(HOST_TEST_SESSION).items.map(({ itemId, body }) => ({ itemId, body })) + (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({ + itemId, + body + })) ).toEqual([ { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from recovery') }, { itemId: agentJournalItemKey(goalRow), body: goalBody } @@ -471,7 +479,7 @@ describe('host rewind', () => { await input.onReverted?.() throw new Error('lost after revert') }) - await expect(host.rewind(caller, params(target))).rejects.toThrow('lost after revert') + await expect(host.rewind(caller, await params(target))).rejects.toThrow('lost after revert') recoverRewind.mockResolvedValueOnce({ ok: true, items }) expect( await host.attach( @@ -479,7 +487,7 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2) + expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(2) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) @@ -487,7 +495,7 @@ describe('host rewind', () => { 'never commits a recovered prefix that omits an expected retained %s', async (missing) => { const target = await seed() - const before = host.journalSnapshot(HOST_TEST_SESSION) + const before = await host.journalSnapshot(HOST_TEST_SESSION) const items = [0, 1].map((ordinal) => ({ identity: { provider: 'codex' as const, @@ -501,7 +509,7 @@ describe('host rewind', () => { await input.onPrepared?.(items) throw new Error('reply lost') }) - await expect(host.rewind(caller, params(target))).rejects.toThrow('reply lost') + await expect(host.rewind(caller, await params(target))).rejects.toThrow('reply lost') recoverRewind.mockResolvedValueOnce({ ok: true, items: missing === 'turn' ? [] : items.slice(0, 1) @@ -522,7 +530,7 @@ describe('host rewind', () => { it('settles the existing epoch after a crash between journal commit and record completion', async () => { const target = await seed() - const request = params(target) + const request = await params(target) const transition = store.transitionHandoff.bind(store) const checkpoint = vi .spyOn(store, 'transitionHandoff') @@ -536,7 +544,7 @@ describe('host rewind', () => { }) ) await expect(host.rewind(caller, request)).rejects.toThrow('completion write failed') - const committed = host.journalSnapshot(HOST_TEST_SESSION) + const committed = await host.journalSnapshot(HOST_TEST_SESSION) expect(committed.cursor.epoch).not.toBe(request.expectedEpoch) checkpoint.mockRestore() const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') @@ -546,7 +554,7 @@ describe('host rewind', () => { hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).toMatchObject({ ok: true }) - expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) + expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) expect(replace).not.toHaveBeenCalled() replace.mockRestore() expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) 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 baedbe7c4d1..45dd7244896 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 @@ -13,6 +13,7 @@ import type { import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' 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' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' @@ -35,6 +36,8 @@ export async function rewindStructuredAgentSession( adapter: context.deps.adapter, callerKey: caller.callerKey, envelope: params.envelope, + // Only the provider can do this, so an agent at rest is started first. + prepareSession: openWithAgent(context, params.envelope), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), flushStreamedEvents: context.flushStreamedEvents, 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 new file mode 100644 index 00000000000..79882025889 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionConversationCommandRecord } from '../../../shared/agent-session-conversation-command' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { + openConversationForWrite, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' + +function withCommand(command: AgentSessionConversationCommandRecord) { + return { ...agentSessionRecordFixture(), conversationCommand: command } +} + +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." + }) + }) + + it('says a committed /clear cleared the conversation', () => { + const blocked = structuredAgentSessionSendBlock( + withCommand({ + ...COMMAND, + command: 'clear', + state: 'completed', + phase: 'committed', + replacementSessionId: 'clear-replacement-1' + }) + ) + + expect(blocked?.refusal).toMatchObject({ + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCleared' } + }) + }) + + 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 a /clear whose new conversation failed to start', () => { + expect( + structuredAgentSessionSendBlock( + withCommand({ ...COMMAND, command: 'clear', state: 'completed', phase: 'committed' }) + ) + ).toBeNull() + }) +}) + +describe('a write whose conversation the host could not open', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + const ENVELOPE = { + sessionId: 'session-1', + clientOperationId: 'operation-1', + expectedRuntimeFence: 1, + payloadFingerprint: '' + } + + function refusedBy(error: unknown) { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + return openConversationForWrite(async () => { + throw error + }, ENVELOPE) + } + + it('says a corrupt history is final, in words and not the error', async () => { + const corrupt = Object.assign(new Error('/Users/me/journal.db: file is not a database'), { + code: 'ERR_SQLITE_ERROR', + errcode: 26 + }) + + expect(await refusedBy(corrupt)).toEqual({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' }, + message: 'Unable to load this chat.' + } + }) + }) + + it('says any other failed open can clear', async () => { + const denied = Object.assign(new Error('EACCES: permission denied, open /Users/me'), { + code: 'EACCES', + errno: -13 + }) + + expect(await refusedBy(denied)).toEqual({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' }, + message: "Orca couldn't open this chat's history right now. Try again." + } + }) + }) +}) 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 ffd06ae4dac..a1f196d8b61 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 @@ -39,7 +39,10 @@ afterEach(async () => { }) /** A host that dies mid-turn, relaunched over a copy of its files taken at the crash. */ -async function relaunchAfterCrashMidTurn(probe: AgentSessionOwnerProbe) { +async function relaunchAfterCrashMidTurn( + probe: AgentSessionOwnerProbe, + { reconcile = true }: { reconcile?: boolean } = {} +) { const dying = hostTestState() await attach() const events = dying.acquire.mock.calls[0]?.[0].events @@ -76,16 +79,17 @@ async function relaunchAfterCrashMidTurn(probe: AgentSessionOwnerProbe) { probeOwner: async () => probe, now: () => NOW }) - await host.reconcileRestartLeases() + if (reconcile) { + await host.reconcileRestartLeases() + } replaceHostTestState({ store, host }) return { host, acquire } } /** Neither proof of the old owner's death is an observed exit, so the turn ends `unverifiable`. */ -function turnStates(host: StructuredAgentSessionHost) { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => readAgentJournalTurn(item.body) ?? []) +async function turnStates(host: StructuredAgentSessionHost) { + return (await host.journalSnapshot(SESSION)).items + .flatMap((item) => readAgentJournalTurn(item.body) ?? []) .map((turn) => turn.state) } @@ -110,16 +114,16 @@ it.each(PROBES)( await expect(host.send(CALLER, { envelope: sendEnvelope, body })).resolves.toMatchObject({ ok: true }) - await eventually(() => + await eventually(async () => expect( - host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === sendEnvelope.clientOperationId) + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === sendEnvelope.clientOperationId + ) ).toMatchObject({ dispatchState: 'rejected' }) ) expect(acquire).toHaveBeenCalledOnce() - expect(turnStates(host)).toEqual(['unverifiable']) + expect(await turnStates(host)).toEqual(['unverifiable']) await host.flushAllStreamedEvents() } ) @@ -131,7 +135,20 @@ it.each(PROBES)( await host.revealSession(SESSION) - expect(turnStates(host)).toEqual(['unverifiable']) + expect(await turnStates(host)).toEqual(['unverifiable']) await host.flushAllStreamedEvents() } ) + +// On desktop the chat on screen at relaunch reads before startup reconciles the leases. +it('settles it when a read reaches the chat before the startup reconcile', async () => { + const { host } = await relaunchAfterCrashMidTurn({ outcome: 'pid-absent' }, { reconcile: false }) + expect(hostTestState().store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + + await host.history({ sessionId: SESSION, direction: 'tail' }) + await host.reconcileRestartLeases() + await host.restoreReadableSessions([SESSION]) + + expect(await turnStates(host)).toEqual(['unverifiable']) + await host.flushAllStreamedEvents() +}) 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 8c8dfec04ce..259ad16c858 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 @@ -13,6 +13,7 @@ import type { import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -26,11 +27,9 @@ import { const CALLER = { callerKey: 'client-1' } /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void): Promise { +function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } -// Long enough that no release fires mid-test; whether one is pending is asserted directly. -const GRACE_MS = 60_000 let root: string let store: AgentSessionRecordStore @@ -83,7 +82,6 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - releaseGraceMs: GRACE_MS, now: () => NOW, onEventSinkError: ({ error }) => hostErrors.push(error) }) @@ -121,28 +119,26 @@ async function accept(params: ReturnType): Promise { return params.envelope.clientOperationId } -function submission(clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submission(clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } /** The submission once delivery is done with it: handed over, or rejected unwritten. */ async function settled(clientMessageId: string) { - await eventually(() => { - const current = submission(clientMessageId) + await eventually(async () => { + const current = await submission(clientMessageId) expect(current?.dispatchState !== 'pending' || current.handedOverAt !== undefined).toBe(true) }) return submission(clientMessageId) } /** The failure rows a start the chat needed left, oldest first. */ -function errorStatuses(): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) +async function errorStatuses(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) } /** The child timed out or exited: its lease is handed back and the host holds no session. */ @@ -161,7 +157,7 @@ describe('a send with no live owner', () => { await accept(sendParams('after the child died')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) @@ -181,7 +177,7 @@ describe('a send with no live owner', () => { }) await accept(sendParams('accept first')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(order).toEqual(['admit', 'acquire']) }) @@ -191,7 +187,7 @@ describe('a send with no live owner', () => { await accept(sendParams('owner is live')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).not.toHaveBeenCalled() }) @@ -217,30 +213,6 @@ describe('a send with no live owner', () => { expect(acquire).not.toHaveBeenCalled() }) - it('renews the idle window on journal activity in an unheld session', async () => { - await loseOwner() - await accept(sendParams('restart')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - const arm = vi.spyOn(host['holds']['clock'], 'arm') - - await accept(sendParams('more activity')) - - await eventually(() => expect(arm).toHaveBeenCalledWith(SESSION)) - }) - - it('releases the restarted child on the usual clock only when no surface holds it', async () => { - await loseOwner() - await accept(sendParams('nobody is watching')) - await eventually(() => expect(host['holds'].isReleasePending(SESSION)).toBe(true)) - - await host.close(SESSION) - // A reading surface that does not itself restart the agent. - await host.hold(SESSION, 'desktop-chat:1', { resume: false }) - await accept(sendParams('the chat is open')) - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(host['holds'].isReleasePending(SESSION)).toBe(false) - }) - it('restarts an owner that exited while the session stayed readable', async () => { const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -256,14 +228,14 @@ describe('a send with no live owner', () => { acquire.mockClear() await accept(sendParams('after an exit')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() }) it('restarts nothing for a resend the journal already answers', async () => { const params = sendParams('sent once') await accept(params) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) // The child died during startup: the lease is handed back, the fence moves, and the session // stays readable. The client resends against the new fence. await host.handleAdapterEvent({ @@ -293,7 +265,7 @@ describe('a send with no live owner', () => { // Retry rotates the id: a genuinely new send restarts the owner once. await accept(sendParams('sent once')) - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) expect(acquire).toHaveBeenCalledOnce() }) @@ -365,7 +337,7 @@ describe('a send with no live owner', () => { release() expect(await second).toMatchObject({ ok: true }) - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) expect(acquire).toHaveBeenCalledOnce() expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([ first, @@ -383,30 +355,14 @@ describe('a send with no live owner', () => { ]) expect(results.map((result) => result.ok)).toEqual([true, true, true]) - await eventually(() => expect(dispatch).toHaveBeenCalledTimes(3)) + await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(3)) expect(acquire).toHaveBeenCalledOnce() }) - it('shares one restart between a hold and a send that arrive in the same gap', async () => { - await loseOwner() - - const [held, sent] = await Promise.allSettled([ - host.hold(SESSION, 'desktop-chat:1'), - host.send(CALLER, sendParams('while the chat opens')) - ]) - - expect(held).toMatchObject({ status: 'fulfilled' }) - expect(sent).toMatchObject({ status: 'fulfilled', value: { ok: true } }) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(host['holds'].isHeld(SESSION)).toBe(true) - expect(host['holds'].isReleasePending(SESSION)).toBe(false) - }) - it('replays into a closed session without spawning anything', async () => { const params = sendParams('sent once') await accept(params) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) await loseOwner() await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) @@ -417,26 +373,62 @@ describe('a send with no live owner', () => { expect(acquire).not.toHaveBeenCalled() expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(host['holds'].isReleasePending(SESSION)).toBe(false) }) it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { await loseOwner() acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) const params = sendParams('while signed out') - const cause = - 'The provider stopped before it finished starting: Not signed in. Run codex login.' + // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. No exit + // was observed, so the chat does not say the provider stopped. + const cause = "Codex couldn't restart. Send your message to try again." const id = await accept(params) - expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause }) + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: cause, + rejection: { kind: 'restartFailed' } + }) expect(dispatch).not.toHaveBeenCalled() // Accepted, so the ledger answers a resend with the rejection rather than a second attempt. expect( store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId) ).toMatchObject({ outcome: { status: 'succeeded' } }) // One row, in the error tone, so the reason outlives the error strip. - expect(errorStatuses()).toEqual([cause]) + expect(await errorStatuses()).toEqual([cause]) + }) + + it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { + await loseOwner() + const said = `no rollout found for thread id ${THREAD}` + acquire.mockRejectedValue( + new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const id = await accept(sendParams('after the thread went away')) + + // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. + const rejection = { + kind: 'restartFailed', + detail: { text: said, audience: 'person' }, + refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } + } + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: "Codex couldn't restart. Send your message to try again.", + rejection + }) + expect(await errorStatuses()).toEqual([ + "Codex couldn't restart. Send your message to try again." + ]) + // Orca's own text is logged once where the start failed. + expect(warn).toHaveBeenCalledWith( + '[agent-session] provider start failed:', + expect.objectContaining({ message: `thread/resume failed: ${said}` }) + ) + warn.mockRestore() }) it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { @@ -453,14 +445,14 @@ describe('a send with no live owner', () => { value: { submission: { dispatchState: 'rejected' } } }) expect(acquire).toHaveBeenCalledTimes(1) - expect(errorStatuses()).toHaveLength(1) + expect(await errorStatuses()).toHaveLength(1) // The outbox's Retry rotates the id: a fresh attempt, with its own row. expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ dispatchState: 'rejected' }) expect(acquire).toHaveBeenCalledTimes(2) - expect(errorStatuses()).toHaveLength(2) + expect(await errorStatuses()).toHaveLength(2) expect(dispatch).not.toHaveBeenCalled() }) @@ -473,7 +465,7 @@ describe('a send with no live owner', () => { // The user signed in; nothing about the failed attempt is remembered. await accept(sendParams('signed in now')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) @@ -491,14 +483,23 @@ describe('a send with no live owner', () => { expect(unresumable).toMatchObject({ dispatchState: 'rejected', - reason: - "Codex couldn't restart: This execution host cannot resume the requested structured agent session. Start a new chat to continue." + reason: "Codex couldn't restart. Start a new chat to continue.", + rejection: { + kind: 'restartFailed', + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' } + } + } }) }) it('rejects the message with the cause when the restart met a lease someone else is settling', async () => { await loseOwner() - vi.spyOn(host['holds'], 'ensureProviderChild').mockResolvedValueOnce({ + vi.spyOn( + host['conversationDelivery'].loop['deps'], + 'ensureProviderChild' + ).mockResolvedValueOnce({ ok: false, refusal: { code: 'execution_owner_reconciling', @@ -508,146 +509,39 @@ describe('a send with no live owner', () => { const id = await accept(sendParams('owner being settled')) - const cause = "Codex couldn't restart: Another runtime is still adjudicating this lease." - expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', reason: cause }) + // The refusal's prose stays out of the chat; its code rides in the fact. + const cause = "Codex couldn't restart. Send your message to try again." + expect(await settled(id)).toMatchObject({ + dispatchState: 'rejected', + reason: cause, + rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } + }) expect(acquire).not.toHaveBeenCalled() - expect(errorStatuses()).toEqual([cause]) + expect(await errorStatuses()).toEqual([cause]) }) it('rejects the message, and reports the fault, when the restart itself faults', async () => { await loseOwner() - vi.spyOn(host['holds'], 'ensureProviderChild').mockRejectedValueOnce( - new Error('spawn-token mint failed') - ) + vi.spyOn( + host['conversationDelivery'].loop['deps'], + 'ensureProviderChild' + ).mockRejectedValueOnce(new Error('spawn-token mint failed')) const id = await accept(sendParams('bookkeeping failed')) + // Orca's own fault: reported to the log, and the chat says only that Orca failed. expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', - reason: "Codex couldn't restart: spawn-token mint failed." + reason: "Orca ran into a problem, so this didn't go through. Try again.", + rejection: { kind: 'hostFault' } }) expect(hostErrors).toContainEqual( expect.objectContaining({ message: 'spawn-token mint failed' }) ) - expect(errorStatuses()).toHaveLength(1) + expect(await errorStatuses()).toHaveLength(1) }) - it('keeps a second surface holder taken during an auto-restart, and starts nothing for it', async () => { - await host.hold(SESSION, 'desktop-chat:1') - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - entered.resolve() - await gate.promise - return spawnChild(input) - }) - - // The held child exits: the host restarts it on its own, under the surface that holds it. - const restarted = host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - await entered.promise - const second = host.hold(SESSION, 'paired-phone:1') - gate.resolve() - await Promise.all([restarted, second]) - - expect(acquire).toHaveBeenCalledOnce() - expect(host['holds']['holders'].holderIds(SESSION)).toEqual([ - 'desktop-chat:1', - 'paired-phone:1' - ]) - expect(host['holds'].isReleasePending(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('puts the child of an auto-restart on the idle clock when its surface left mid-attach', async () => { - await host.hold(SESSION, 'desktop-chat:1') - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - entered.resolve() - await gate.promise - return spawnChild(input) - }) - - const restarted = host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - await entered.promise - // The only surface leaves while the host is still spawning the child it asked for. - host.release(SESSION, 'desktop-chat:1') - gate.resolve() - await restarted - - expect(acquire).toHaveBeenCalledOnce() - expect(host['holds'].isHeld(SESSION)).toBe(false) - // Nobody holds the child, so it goes on the same clock a departed surface would start. - expect(host['holds'].isReleasePending(SESSION)).toBe(true) - expect(hostErrors).toEqual([]) - }) - - it('counts a queued restart as in flight from the moment it is asked for, so a quit drains it', async () => { - const closeSession = vi.mocked(host.deps.adapter.closeSession!) - acquire.mockClear() - const gate = Promise.withResolvers() - closeSession.mockImplementationOnce(async () => { - await gate.promise - return true - }) - const closing = host.close(SESSION) - const hold = host.hold(SESSION, 'desktop-chat:1') - let drained = false - void host['tasks'].drainAttaches().then(() => { - drained = true - }) - await new Promise((resolve) => setImmediate(resolve)) - - // The hold waits its turn behind the close, and the quit's drain waits for the hold: the - // child it is about to spawn must exist before the quit decides what to evict. - expect(acquire).not.toHaveBeenCalled() - expect(drained).toBe(false) - - gate.resolve() - await Promise.all([closing, hold]) - await new Promise((resolve) => setImmediate(resolve)) - expect(acquire).toHaveBeenCalledOnce() - expect(drained).toBe(true) - }) - - it('adjudicates a lease this host has not reconciled before a hold resumes it', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) - host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - - await host.hold(SESSION, 'desktop-chat:1') - - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - unreconciled: false, - claimStatus: 'live' - }) - }) - - it('exits the recovery stage a failed attempt latched before a hold resumes', async () => { + it('exits the recovery stage a failed attempt latched before its delivery resumes it', async () => { await loseOwner() // What an acquisition whose exit could not be proven leaves behind: nobody's, but latched. await store.transitionHandoff(SESSION, (current) => ({ @@ -656,8 +550,9 @@ describe('a send with no live owner', () => { })) host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - await host.hold(SESSION, 'desktop-chat:1') + await accept(sendParams('latched owner')) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -675,7 +570,7 @@ describe('a send with no live owner', () => { // The send's start is the same serialized resume a hold runs, reconciliation first. await accept(sendParams('owner unverified')) - await eventually(() => expect(dispatch).toHaveBeenCalledOnce()) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ unreconciled: false, @@ -706,7 +601,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { it("keeps the pane's fence on the rows a failed start publishes, and rejects the message", async () => { const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const frames: AgentSessionSubscribeEvent[] = [] - host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) + await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) await loseOwner() acquire.mockRejectedValueOnce(new Error('Not signed in')) const subscribed = frames.length @@ -715,7 +610,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { expect(await settled(id)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining('Not signed in') + rejection: { kind: 'restartFailed' } }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) const published = frames.slice(subscribed) @@ -767,7 +662,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { expect(await settled(late.envelope.clientOperationId)).toMatchObject({ dispatchState: 'accepted' }) - expect(submission(firstParams.envelope.clientOperationId)).toMatchObject({ + expect(await submission(firstParams.envelope.clientOperationId)).toMatchObject({ dispatchState: 'rejected' }) expect(acquire).toHaveBeenCalledOnce() 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 1fa923e971f..a7a0e971055 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 @@ -5,45 +5,25 @@ // or a journal that cannot be opened. import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { - AgentSessionMutationEnvelope, - AgentSessionWireRefusal -} from '../../../shared/agent-session-wire' -import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire-refusals' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import { - ownerRestartFailedOutcome, - providerStartupFailureOutcome -} from './structured-agent-session-dead-generation-settlement' + refuse, + type AgentSessionMutationEnvelope, + type AgentSessionWireRefusal +} 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 { + classifyJournalOpenFailure, + type JournalOpenFailure +} from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED, type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' import { rewindRefusal } from './structured-rewind-refusal' - -/** - * Whether a refused start leaves the chat anything to start again from. `unresumable`: this host - * has nothing to restart it from — no record, or none it can run — so only a new chat continues. - * A new wire code does not compile until it is classified here. - */ -const START_REFUSAL_RESUMABLE: Record = { - execution_owner_reconciling: true, - agent_session_conflict: true, - agent_session_checkpoint_stale: true, - agent_session_ownership_unknown: true, - agent_session_operation_capacity: true, - structured_agent_session_unsupported: false, - agent_session_operation_conflict: true, - agent_session_operation_expired: true, - agent_session_operation_invalid: true, - agent_session_operation_unknown: true, - agent_session_item_revision_stale: true, - agent_session_already_resolved: true, - agent_session_identity_required: false, - agent_session_journal_unreadable: true, - agent_session_owner_restart_failed: true -} +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' /** Why the record refuses any send right now, whoever owns it; null when a send may run. */ export function structuredAgentSessionSendBlock( @@ -61,12 +41,25 @@ export function structuredAgentSessionSendBlock( ) { return { ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: command.replacementSessionId - ? 'This conversation has been cleared. Use the current conversation.' - : 'The conversation operation is unconfirmed.' - } + 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.' + ) } } return null @@ -83,30 +76,58 @@ 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: { - code: 'agent_session_journal_unreadable', - message: `The conversation could not be opened: ${ - error instanceof Error ? error.message : String(error) - }` - } + refusal: refuse('agent_session_journal_unreadable', { reason }, JOURNAL_OPEN_MESSAGE[reason]) } } } -/** What the chat says, in its row and on every message it rejects, when the delivery loop could - * not make the session ready. A child that died starting reads as any start that died does. */ -export function structuredAgentSessionStartFailureText( - record: AgentSessionRecord | null, - cause: AgentSessionWireRefusal -): string { - if (cause.ownerVerdict === 'exited') { - return providerStartupFailureOutcome(cause.message) +// 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, + envelope: AgentSessionMutationEnvelope +): () => Promise { + return () => openConversationForWrite(context.openConversation, envelope) +} + +/** For an operation only the provider can perform: the conversation, then its agent. */ +export function openWithAgent( + context: Pick, + envelope: AgentSessionMutationEnvelope +): () => Promise { + return async () => { + const opened = await openConversationForWrite(context.openConversation, envelope) + return opened.ok ? context.ensureAgent(envelope.sessionId) : opened } - return ownerRestartFailedOutcome({ - agentName: record ? TUI_AGENT_DISPLAY_NAMES[record.provider] : 'The agent', - reason: cause.message, - resumable: START_REFUSAL_RESUMABLE[cause.code] - }) +} + +/** A rewind still in doubt once the conversation is open is one only its provider can settle — + * the open settles every other — so a send starts the agent, whose attach recovers it. */ +export function sendPreparation( + context: Pick, + envelope: AgentSessionMutationEnvelope +): () => Promise { + return async () => { + const opened = await openConversationForWrite(context.openConversation, envelope) + const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase + return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded') + ? context.ensureAgent(envelope.sessionId) + : opened + } +} + +/** Who a failure sentence names: the chat's agent, when the record says. */ +export function structuredAgentSessionFailureWordsContext( + record: AgentSessionRecord | null +): AgentSessionFailureWordsContext { + return record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {} } 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 211d8684e15..f339aa1113f 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 @@ -11,6 +11,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 { 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' @@ -21,6 +22,7 @@ import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, hostTestAttachParams, + hostTestDrawnRowIds, hostTestMessage, hostTestOperationId, resetHostTestOperationIds @@ -29,7 +31,7 @@ import { const CALLER = { callerKey: 'client-1' } /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void): Promise { +function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -70,7 +72,9 @@ async function send( value: { submission: { dispatchState: 'pending', handoverRecorded: true } } }) const clientMessageId = sent.ok ? sent.value.clientMessageId : '' - await eventually(() => expect(submission(clientMessageId)?.handedOverAt).toBeDefined()) + await eventually(async () => + expect((await submission(clientMessageId))?.handedOverAt).toBeDefined() + ) return clientMessageId } @@ -97,21 +101,27 @@ function exitBeforeProof(): Promise { type: 'ended', ...currentChild(), reason: EXIT_REASON, + failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, cause: 'unexpected-exit', startupUnproven: true }) } -function journalStatuses(): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) +const STARTUP_FAILURE = { + kind: 'providerStartFailed', + detail: { text: EXIT_REASON, audience: 'log' } } -function submission(clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function journalStatuses(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) +} + +async function submission(clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } beforeEach(async () => { @@ -188,24 +198,34 @@ describe('a send into a published session whose child ended before startup', () it('retires the held message with the cause when the restarted child exits before proving its start', async () => { const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const rowsBefore = journalStatuses().length + const rowsBefore = (await journalStatuses()).length const held = await send('still not signed in', releasedFence) await exitBeforeProof() // The child never proved its start, so it accepted nothing: the exit rejects the message this // host admitted, so nothing pins the session and Retry stays offered, and one row names the cause. - expect(submission(held)).toMatchObject({ + expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining(EXIT_REASON), + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: STARTUP_FAILURE, recovered: true }) expect( - host.journalSnapshot(SESSION).submissions.filter((e) => e.dispatchState === 'pending') + (await host.journalSnapshot(SESSION)).submissions.filter((e) => e.dispatchState === 'pending') ).toEqual([]) - expect(journalStatuses().slice(rowsBefore)).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) + expect((await journalStatuses()).slice(rowsBefore)).toEqual([ + 'Codex stopped before it finished starting. Send your message to try again.' ]) + // Accepted before the restart it needed, so the chat draws it above the row naming the cause. + const snapshot = await host.journalSnapshot(SESSION) + const causeRow = snapshot.items.findLast((item) => item.body.kind === 'status')?.itemId + const shown = [agentJournalSubmissionKey(held), causeRow] + expect( + hostTestDrawnRowIds(snapshot, [ + { clientMessageId: held, text: 'still not signed in' } + ]).filter((id) => shown.includes(id)) + ).toEqual(shown) // The failed restart moved the fence twice: the acquisition, and the exit that released it. expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(releasedFence + 2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -216,7 +236,7 @@ describe('a send into a published session whose child ended before startup', () await send('signed in now') expect(acquire).toHaveBeenCalledTimes(3) expect(dispatch).toHaveBeenCalledTimes(2) - expect(journalStatuses().slice(rowsBefore)).toHaveLength(1) + expect((await journalStatuses()).slice(rowsBefore)).toHaveLength(1) }) }) @@ -230,7 +250,7 @@ describe('a send while the child of the first start is still proving itself', () await proveStarted() expect(acquire).toHaveBeenCalledOnce() - expect(journalStatuses()).toEqual([]) + expect(await journalStatuses()).toEqual([]) }) it('is retired with the cause when that child exits first, and restarts nothing', async () => { @@ -239,14 +259,15 @@ describe('a send while the child of the first start is still proving itself', () await exitBeforeProof() - expect(submission(held)).toMatchObject({ + expect(await submission(held)).toMatchObject({ dispatchState: 'rejected', - reason: expect.stringContaining(EXIT_REASON), + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: STARTUP_FAILURE, recovered: true }) expect(acquire).toHaveBeenCalledOnce() - expect(journalStatuses()).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in/) + expect(await journalStatuses()).toEqual([ + 'Codex stopped before it finished starting. Send your message to try again.' ]) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence + 1) }) 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 1a6e5606c1c..d2b9d27c1a0 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 @@ -23,6 +23,8 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' let store: AgentSessionRecordStore let host: StructuredAgentSessionHost @@ -75,7 +77,7 @@ describe('send', () => { dispatchState: 'accepted' }) expect(dispatch).toHaveBeenCalledTimes(1) - const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(1) expect(page.ok && page.page.fence).toBe(1) expect(page.page.hostNow).toBe(NOW) @@ -140,7 +142,7 @@ describe('send', () => { value: { submission: { dispatchState: 'unknown' } } }) expect(dispatch).toHaveBeenCalledTimes(1) - const state = host.history({ sessionId: SESSION, direction: 'tail' }) + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(state.ok && state.page.submissions).toHaveLength(1) }) @@ -167,7 +169,7 @@ describe('send', () => { } }) expect(dispatch).toHaveBeenCalledTimes(1) - const state = host.history({ sessionId: SESSION, direction: 'tail' }) + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(state.ok && state.page.submissions).toHaveLength(1) }) @@ -176,7 +178,9 @@ describe('send', () => { dispatch .mockImplementationOnce(async () => ({ state: 'rejected' as const, - reason: 'provider_write_failed: broken pipe' + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { + surface: 'rejection' + }) })) .mockImplementationOnce(async () => accepted()) const body = hostTestMessage('never written') @@ -185,7 +189,7 @@ describe('send', () => { await host.send(CALLER, params) await expect(delivered(params.envelope.clientOperationId)).resolves.toMatchObject({ dispatchState: 'rejected', - reason: 'provider_write_failed: broken pipe' + reason: 'provider_write_failed' }) // What the user's Retry does with a rejection: a fresh client message id, // which is a first delivery by construction and cannot duplicate the frame @@ -196,7 +200,7 @@ describe('send', () => { dispatchState: 'accepted' }) expect(dispatch).toHaveBeenCalledTimes(2) - const state = host.history({ sessionId: SESSION, direction: 'tail' }) + const state = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(state.ok && state.page.submissions).toHaveLength(2) }) 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 711e38b29b8..7602afa230b 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 @@ -125,6 +125,11 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) +/** The host starting the agent with no message to deliver, as an operation that needs it does. */ +function startAgent(): Promise { + return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) +} + describe('settled attach retry', () => { it('settles a post-acquisition journal failure and retries without a restart', async () => { const historyFilePath = vi @@ -329,7 +334,7 @@ describe('settled attach retry', () => { now: () => NOW }) await host.restoreReadableSessions() - await host.hold(SESSION, 'desktop-chat:restart') + await startAgent() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, @@ -346,7 +351,7 @@ describe('settled attach retry', () => { throw new Error(`unexpected restored send refusal: ${sent.refusal.message}`) } await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) - const restoredHistory = host.history({ sessionId: SESSION, direction: 'tail' }) + const restoredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) if (!restoredHistory.ok) { throw new Error(`unexpected restored history reset: ${restoredHistory.reset}`) } @@ -378,7 +383,10 @@ describe('settled attach retry', () => { await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: false, - refusal: { message: 'resume rejected', ownerVerdict: 'exited' } + refusal: { + message: "Codex couldn't restart. Send your message to try again.", + ownerVerdict: 'exited' + } }) expect(releaseAcquisition).toHaveBeenCalledTimes(1) 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 new file mode 100644 index 00000000000..5598cd3c4f7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -0,0 +1,97 @@ +// A turn an exited agent left running, whose settlement write failed, is settled by the next open of +// the conversation, not only by the next send: a reader reopening a chat the idle sweep closed, or +// reading it before the restart restore reaches it. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' +import { + collectSubscriber, + createRestTestRig, + foundRestTestChat, + IDLE_MS, + REST_TEST_SESSION as SESSION, + REST_TEST_THREAD, + sweepOnce, + type RestTestRig +} from './structured-agent-session-rest-test-rig' + +let rig: RestTestRig + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +/** A turn in flight whose agent exits, and whose exit settlement the journal refuses. */ +async function exitWithUnwrittenSettlement(): Promise { + await foundRestTestChat(rig) + const open = rig.host.collaboratorsForTests().sessions.get(SESSION)! + const running = open.child! + rig.adapter.acquire.mock.calls + .at(-1)?.[0] + .events?.appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, + { kind: 'turn', turnId: 'working', state: 'running' } + ) + await rig.host.flushStreamedEvents(SESSION) + vi.spyOn(open.journal, 'appendLifecycleBatch').mockRejectedValueOnce(new Error('disk full')) + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'killed', + cause: 'unexpected-exit', + fence: running.fence, + acquisitionGeneration: running.generation! + }) + await vi.waitFor(() => + expect(rig.store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed' } + }) + ) +} + +async function workingTurnState(): Promise { + const items = (await rig.host.journalSnapshot(SESSION)).items + return items + .map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'working')?.state +} + +describe('a turn its gone agent left running', () => { + it('is settled when the idle-closed conversation is opened again, and its reader sees it', async () => { + await exitWithUnwrittenSettlement() + expect(await workingTurnState()).toBe('running') + rig.clock.now += IDLE_MS + 1 + await sweepOnce(rig.host) + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + + const reader = collectSubscriber() + await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) + + expect(await workingTurnState()).toBe('interrupted') + // The death evidence is Orca's log text: the row says only that the provider stopped. + expect(JSON.stringify(reader.events)).toContain( + 'Codex stopped while this response was in progress. You can continue in this conversation.' + ) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + }) + + it('is settled by a read after a restart that reaches the chat before the restore does', async () => { + await exitWithUnwrittenSettlement() + await rig.restart() + setStructuredAgentSessionHost(rig.host) + + // The client's read opens the chat first; the restore then finds it open and skips it. + expect(await workingTurnState()).toBe('interrupted') + await rig.host.restoreReadableSessions([SESSION]) + expect(await workingTurnState()).toBe('interrupted') + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + }) +}) 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 00594966101..77ba7fe5f58 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,25 +1,43 @@ +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' 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' -import { boundJournalStatusText } from '../agent-session-journal/journal-prompt-body-bounds' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' + +/** A start that failed, keyed like its row, in the words `structuredAgentSessionStartFailure` gave. */ +export type StructuredAgentSessionStartFailure = StructuredAgentSessionStartFailureWords & { + startKey: string | null +} /** * The one row a start that failed leaves in the chat, whoever saw it fail: an error row, so the * reason outlives any error strip. Keyed by the start — the child's generation, or the oldest * message it was for when no child was ever published — so a second report of the same failure - * revises the row instead of adding one. + * lands on the same row instead of adding one. */ export function structuredAgentSessionStartFailureRow( startKey: string, - text: string + words: StructuredAgentSessionStartFailureWords ): JournalLifecycleMutationInput { return { kind: 'item', - identity: { provider: 'orca', clientMessageId: `start-failure:${startKey}` }, - body: { kind: 'status', text: boundJournalStatusText(text), tone: 'error' } + 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 } } } +/** Whether the start's row is already written. Its words are the ones its rejected messages carry, + * and rejected is terminal, so the exit's later report of the same start must not reword it. */ +export function hasStructuredAgentSessionStartFailureRow( + items: readonly { itemId: string }[], + startKey: string +): boolean { + const itemId = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) + return items.some((item) => item.itemId === itemId) +} + /** * A start the delivery loop needed and did not get: the start's row, and every queued message * rejected with the same words. Writes nothing when nothing is still queued: a start whose @@ -27,7 +45,7 @@ export function structuredAgentSessionStartFailureRow( */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, - failure: { startKey: string | null; text: string } + failure: StructuredAgentSessionStartFailure ): Promise { const oldest = oldestQueuedSubmission(session) if (!oldest) { @@ -38,9 +56,12 @@ export async function recordStructuredAgentSessionStartFailure( settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, failure.text)] + mutations: [structuredAgentSessionStartFailureRow(startKey, failure)] + }) + await session.journal.rejectQueuedSubmissions(session.fence, { + reason: failure.reason, + rejection: failure.rejection }) - await session.journal.rejectQueuedSubmissions(session.fence, failure.text) } export function oldestQueuedSubmission( 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 new file mode 100644 index 00000000000..5ce1fdb3694 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -0,0 +1,194 @@ +// A start a queued message waited on can be seen failing twice: by the delivery loop, when the +// adapter settles the start without proving it, and by the exit settlement, when the child's exit +// lands. The chat gets one row for that start, the loop's, in the words the message was rejected +// with — whichever of the two reports first. + +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 { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +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 { 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' + +const CALLER = { callerKey: 'client-1' } +const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' +const ADAPTER_FAILURE = agentSessionFailureFact('notSignedIn') +const ADAPTER_FAILURE_TEXT = + 'Codex is not signed in for the selected account. Sign in, then send your message again.' +// The exit's reason is Orca's log text; the row says only that the start stopped. +const EXIT_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' +// The first child (generation-1) is lost at setup; the send starts generation-2. +const START_ROW = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'start-failure:generation-2' +}) + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let generation = 0 +let settleStart: (failure: SubmissionRejectionFact | undefined) => void = () => {} +let awaitStarted = vi.fn<() => Promise>() +let frames: AgentSessionSubscribeEvent[] = [] + +function exitBeforeProof(): Promise { + return host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + acquisitionGeneration: `generation-${generation}`, + reason: EXIT_REASON, + cause: 'unexpected-exit', + startupUnproven: true + }) +} + +async function sendQueued(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent).toMatchObject({ ok: true }) + // The loop started a child and waits on its start with the message still queued. + await eventually(() => expect(awaitStarted).toHaveBeenCalledOnce()) + expect(generation).toBe(2) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function submission(clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +/** Every text the subscriber was sent for the start's row, in order. */ +function publishedStartRows(): string[] { + return frames.flatMap((frame) => + frame.type === 'batch' + ? frame.batch.items.flatMap((item) => + item.itemId === START_ROW && item.body.kind === 'status' ? [item.body.text] : [] + ) + : [] + ) +} + +async function startRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.itemId === START_ROW && item.body.kind === 'status' ? [item.body.text] : [] + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-start-failure-writer-')) + resetHostTestOperationIds() + generation = 0 + frames = [] + awaitStarted = vi.fn( + () => new Promise((resolve) => (settleStart = resolve)) + ) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: generation === 0 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${++generation}`, + providerChildPhase: 'starting' as const + })), + awaitStarted, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation + 1}`, + now: () => NOW + }) + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) + await exitBeforeProof() + await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a queued message whose start fails and whose child then exits', () => { + it("keeps the loop's row when the loop saw the failure first", async () => { + const queued = await sendQueued('hello') + + settleStart(ADAPTER_FAILURE) + await eventually(async () => + expect(await submission(queued)).toMatchObject({ + dispatchState: 'rejected', + reason: ADAPTER_FAILURE_TEXT, + rejection: ADAPTER_FAILURE + }) + ) + await exitBeforeProof() + await host.flushStreamedEvents(SESSION) + + expect(await startRows()).toEqual([ADAPTER_FAILURE_TEXT]) + expect(publishedStartRows()).toEqual([ADAPTER_FAILURE_TEXT]) + }) + + it('leaves the row to the loop when the exit lands while the message still waits', async () => { + const queued = await sendQueued('hello') + + await exitBeforeProof() + expect(await submission(queued)).toMatchObject({ dispatchState: 'pending' }) + settleStart(undefined) + await eventually(async () => + expect(await submission(queued)).toMatchObject({ dispatchState: 'rejected' }) + ) + await host.flushStreamedEvents(SESSION) + + expect(await startRows()).toEqual([EXIT_TEXT]) + // Written once, after the message was settled, not first by the exit and again by the loop. + expect(publishedStartRows()).toEqual([EXIT_TEXT]) + }) +}) 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 88774c63bed..dae8635cd69 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,7 +4,6 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { providerStartupFailureOutcome } from './structured-agent-session-dead-generation-settlement' import { settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, @@ -13,7 +12,8 @@ import { const SESSION = 'session-1' const GENERATION = 'generation-1' -const REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' +const REASON = 'claude stream-json exited (code 1): session limit reached' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' function startedSession(): StructuredAgentSessionUnexpectedExitSession & { journal: { appendLifecycleBatch: ReturnType } @@ -55,7 +55,6 @@ function contextFor(session: StructuredAgentSessionUnexpectedExitSession) { sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1 } @@ -72,35 +71,40 @@ const ended = { } describe('a provider that ends before it finished starting', () => { - it('tells the user why, even with no response in progress, and does not auto-resume', async () => { + it('tells the user why, even with no response in progress', async () => { const session = startedSession() - const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { ...ended, + // The adapter typed the start's own failure; the host keeps it rather than reword it. + failure: { kind: 'notSignedIn' }, startupUnproven: true }) - expect(ticket).toBeNull() expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( expect.objectContaining({ mutations: [ expect.objectContaining({ // The same row the delivery loop writes for a failed start: an error, keyed by it. identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, - body: { kind: 'status', text: providerStartupFailureOutcome(REASON), tone: 'error' } + body: { + kind: 'status', + text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', + tone: 'error', + failure: { kind: 'notSignedIn' } + } }) ] }) ) - expect(providerStartupFailureOutcome(REASON)).toContain('not signed in') }) - it('keeps an ordinary idle exit silent and resumable', async () => { + it('keeps an ordinary idle exit silent', async () => { const session = startedSession() - const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) - expect(ticket).not.toBeNull() + expect(session.child).toBeNull() expect(session.journal.appendLifecycleBatch).not.toHaveBeenCalled() }) @@ -110,16 +114,27 @@ describe('a provider that ends before it finished starting', () => { child: { generation: GENERATION, fence: 7, phase: 'starting' as const } } - const ticket = await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), { + ...ended, + failure: { kind: 'providerExited', detail: { text: REASON, audience: 'log' } } + }) - expect(ticket).toBeNull() expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( expect.objectContaining({ mutations: [ expect.objectContaining({ // The same row the delivery loop writes for a failed start: an error, keyed by it. identity: { provider: 'orca', clientMessageId: `start-failure:${GENERATION}` }, - body: { kind: 'status', text: providerStartupFailureOutcome(REASON), tone: 'error' } + // The exit's stderr stays out of the sentence, as a log detail beside it. + body: { + kind: 'status', + text: STARTUP_TEXT, + tone: 'error', + failure: { + kind: 'providerStartFailed', + detail: { text: REASON, audience: 'log' } + } + } }) ] }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index c301cb32233..05937aca514 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -62,6 +62,8 @@ export type StructuredAgentSessionStatusFeedDeps = { /** Live provider-owned background tasks for the summary, so session lists can * render subagent children. Optional: a provider without the hook projects none. */ readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined + /** The session's agent proved a start: its row's phase became `ready`. */ + onAgentStarted?: (sessionId: string) => void } function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { @@ -119,6 +121,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: { onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink } + onAgentStarted?: (sessionId: string) => void }): StructuredAgentSessionStatusFeed { return new StructuredAgentSessionStatusFeed({ sessions: args.sessions, @@ -128,7 +131,8 @@ export function createStructuredAgentSessionHostStatusFeed(args: { readBackgroundTasks: (sessionId) => args.deps().adapter.backgroundTaskState?.(sessionId), // Resolved per call for the same reason the other deps are: the host builds this feed in a // field initializer, before its constructor parameters are assigned. - statusSink: () => args.deps().statusSink + statusSink: () => args.deps().statusSink, + ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}) }) } @@ -236,6 +240,9 @@ export class StructuredAgentSessionStatusFeed { this.published.set(sessionId, summary) this.sink(summary, session.params.location) this.broadcast({ type: 'status', session: summary }) + if (summary.hostExecutionPhase === 'ready' && previous?.hostExecutionPhase !== 'ready') { + this.deps.onAgentStarted?.(sessionId) + } try { this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) } catch (error) { 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 0b54ea7c430..f600d6dd9bb 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 @@ -58,6 +58,10 @@ export class AgentSessionSubscribers { return this.activityBySession.size } + subscriberCountForTests(sessionId: string): number { + return this.bySession.get(sessionId)?.size ?? 0 + } + open(input: { id: string sessionId: string 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 cc145ad7995..98e50783bdf 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,7 +1,8 @@ -// The lifetime of a provider child, from the surfaces that hold the session. +// The lifetime of a provider child, against the real host rather than a double. // -// Two leaks meet here and each has to be tested against the real host, not a double: a chat that -// closes without stopping its app-server, and a launch that starts one for every record on disk. +// Two leaks meet here: a chat that closes without stopping its app-server, and a launch that +// starts one for every record on disk. A view never starts or keeps a child; work starts one, the +// idle sweep stops it, and an exit is settled and left for the next send. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -16,7 +17,6 @@ import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { AgentSessionAcquisitionRootExitObservedError, @@ -24,7 +24,7 @@ import { } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { unexpectedProviderExitOutcome } from './structured-agent-session-dead-generation-settlement' +import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' import { @@ -36,11 +36,16 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' + +const UNEXPECTED_PROVIDER_EXIT_OUTCOME = + 'Codex stopped while this response was in progress. You can continue in this conversation.' const CALLER = { callerKey: 'client-1' } -const SURFACE = 'desktop-chat:1' -/** Short enough to keep the suite fast, long enough that an eviction is a decision and not a race. */ -const GRACE_MS = 5 +/** Short enough to keep the suite fast; the host clock below decides what is idle. */ +const SWEEP_MS = 5 +const IDLE_MS = 1_000 let root: string let store: AgentSessionRecordStore @@ -51,6 +56,7 @@ let dispatch: Mock let sink: StructuredAgentSessionEventSink | null let hostErrors: unknown[] let statusSink: StructuredAgentSessionStatusSink +let clock: number function adapter(): StructuredAgentSessionAdapter { return { acquire, @@ -72,8 +78,8 @@ function openHost( journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - releaseGraceMs: GRACE_MS, - now: () => NOW, + idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, + now: () => clock, onEventSinkError: ({ error }) => hostErrors.push(error), statusSink, ...(probeOwner ? { probeOwner } : {}) @@ -93,6 +99,12 @@ async function attach(): Promise { expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) } +/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ +async function startAgent(): Promise { + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) +} + function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { return { sessionId: SESSION, @@ -113,7 +125,7 @@ function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): voi ) } -/** Eviction is a sequence, not an event: the child stops first and the session is forgotten last. */ +/** The sweep stops the child first and closes the conversation last. */ function waitForEviction(): Promise { return vi.waitFor(() => { expect(closeSession).toHaveBeenCalledWith(SESSION) @@ -121,9 +133,9 @@ function waitForEviction(): Promise { }) } -/** Long enough for several grace windows to elapse, so "not evicted" means the clock declined. */ -function waitOutSeveralGraceWindows(): Promise { - return new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) +/** Long enough for many sweep ticks, so "not stopped" means the sweep declined. */ +function waitOutSeveralSweeps(): Promise { + return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) } /** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. */ @@ -189,6 +201,7 @@ beforeEach(async () => { resetHostTestOperationIds() sink = null hostErrors = [] + clock = NOW statusSink = { publish: vi.fn(), forget: vi.fn() } let generation = 0 acquire = vi.fn(async ({ fence, spawnToken, events }) => { @@ -208,7 +221,12 @@ beforeEach(async () => { } }) closeSession = vi.fn(async () => true) - dispatch = vi.fn(async () => ({ state: 'rejected' as const, reason: 'unused' })) + dispatch = vi.fn(async () => ({ + state: 'rejected' as const, + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + })) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) openHost() }) @@ -219,13 +237,13 @@ afterEach(async () => { }) describe('a chat that closes', () => { - it('releases the provider child it was holding', async () => { + it('stops the provider child it started', async () => { await attach() - await host.hold(SESSION, SURFACE) - host.release(SESSION, SURFACE) + await host.close(SESSION) - await waitForEviction() + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(host.hasSession(SESSION)).toBe(false) expect(hostErrors).toEqual([]) // The record and its journal stay; only the process and the claim on it go. expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -235,49 +253,24 @@ describe('a chat that closes', () => { }) }) - it('keeps the child while another surface still holds the session', async () => { - await attach() - await host.hold(SESSION, SURFACE) - await host.hold(SESSION, 'paired-phone:1') - - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - // The pane outlives the close by a few frames — a workspace delete closes the chats inside it - // while their panes are still mounted — so whatever a read raises in that window is what the user - // sees. This is the code the client narrows on to keep that window off the pane; a host that - // starts raising a different one there puts the red error back. - it('answers a read from the pane that outlived it with the code the client treats as transitional', async () => { + // while their panes are still mounted. A read in that window reopens the conversation as a cache + // and is answered, never refused; it starts nothing. + it('answers a read from the pane that outlived it without starting a child', async () => { await attach() - await host.hold(SESSION, SURFACE) await host.close(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - expect(() => host.history({ sessionId: SESSION, direction: 'tail' })).toThrow( - AGENT_SESSION_UNATTACHED_REFUSAL_CODE - ) - expect(() => - host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: () => undefined }) - ).toThrow(AGENT_SESSION_UNATTACHED_REFUSAL_CODE) - }) - it('does not lose the session to a release the client sent twice', async () => { - await attach() - await host.hold(SESSION, SURFACE) - await host.hold(SESSION, 'paired-phone:1') - - // A retried release must retire ONE holder, which is what a set gets right and a count does not. - host.release(SESSION, SURFACE) - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) + const unsubscribe = await host.subscribe({ + id: 'sub-1', + sessionId: SESSION, + emit: () => undefined + }) + unsubscribe() + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) it('answers a compatibility wait with what eviction recorded', async () => { @@ -309,7 +302,7 @@ describe('a chat that closes', () => { }) }) - it('retries teardown after journal close loses its result', async () => { + it('keeps the stop it made when the journal close loses its result', async () => { await attach() const session = host['sessions'].get(SESSION) expect(session).toBeDefined() @@ -321,25 +314,16 @@ describe('a chat that closes', () => { }) .mockImplementation(closeJournal) - // The child stopped and its lease went back; only the conversation's close is left to retry. + // The child is stopped and the lease released before the handle closes; the entry is dropped + // before that close, so a lost result leaves no closing handle for a reader to find. await expect(host.close(SESSION)).rejects.toThrow('journal close result lost') - expect(host.hasSession(SESSION)).toBe(true) - expect(host['sessions'].get(SESSION)?.child).toBeNull() + expect(host.hasSession(SESSION)).toBe(false) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null }) - expect(statusSink.forget).toHaveBeenCalledWith({ - kind: 'structured-session', - sessionId: SESSION, - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }) await expect(host.close(SESSION)).resolves.toBeUndefined() - expect(host.hasSession(SESSION)).toBe(false) expect(closeSession).toHaveBeenCalledOnce() }) @@ -376,31 +360,31 @@ describe('a chat that closes', () => { }) describe('a session with a turn in flight', () => { - it('is not evicted while the turn runs, and is once it ends', async () => { + it('is not stopped while the turn runs, and is once it ends and idles', async () => { await attach() - await host.hold(SESSION, SURFACE) emitTurnLifecycle('running', 1) await host.flushStreamedEvents(SESSION) - host.release(SESSION, SURFACE) - await waitOutSeveralGraceWindows() + clock += IDLE_MS + await waitOutSeveralSweeps() expect(closeSession).not.toHaveBeenCalled() expect(host.hasSession(SESSION)).toBe(true) emitTurnLifecycle('completed', 2) await host.flushStreamedEvents(SESSION) + clock += IDLE_MS await waitForEviction() }) - // Codex settles an admitted send only on its echo, which may never come; eviction retires it. - it('is evicted with an admitted send outstanding once no turn runs', async () => { + // Codex settles an admitted send only on its echo, which may never come; the stop retires it. + it('is stopped with an admitted send outstanding once no turn runs', async () => { await attach() - await host.hold(SESSION, SURFACE) await sendPending('admitted, never echoed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - host.release(SESSION, SURFACE) + clock += IDLE_MS await waitForEviction() }) @@ -410,16 +394,13 @@ describe('startup', () => { it('settles an idle absent owner without chat pollution and resumes the same provider identity', async () => { await attach() const beforeRestart = store.getRecord(SESSION) - host['runtimeState'].stopLeaseRenewal() - host['holds'].dispose() - await host['sessions'].get(SESSION)?.journal.close() - host['sessions'].clear() + await abandonStructuredAgentSessionHost(host) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) openHost(async () => ({ outcome: 'pid-absent' })) await host.restoreReadableSessions() - const restored = host.history({ sessionId: SESSION, direction: 'tail' }) + const restored = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(restored.ok && restored.page.items.some((item) => item.body.kind === 'status')).toBe( false ) @@ -428,7 +409,7 @@ describe('startup', () => { ownerProcess: null }) - await host.hold(SESSION, SURFACE) + await startAgent() expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( beforeRestart?.providerHandleChain.at(-1)?.handle ) @@ -446,16 +427,23 @@ describe('startup', () => { expect(host.listSessionTabs()).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) - expect(host.history({ sessionId: SESSION, direction: 'tail' }).ok).toBe(true) + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) - it('gives the child back to a chat a surface actually opens', async () => { + it('gives the child back for work, never for a read', async () => { await attach() await reboot() await host.restoreReadableSessions() - await host.hold(SESSION, SURFACE) + const unsubscribe = await host.subscribe({ + id: 'viewer-1', + sessionId: SESSION, + emit: () => undefined + }) + expect(acquire).not.toHaveBeenCalled() + await startAgent() + unsubscribe() expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -466,16 +454,15 @@ describe('startup', () => { }) }) -describe('a session evicted and opened again', () => { +describe('a session closed and started again', () => { it('publishes provider events to the reattached chat', async () => { await attach() - await host.hold(SESSION, SURFACE) - host.release(SESSION, SURFACE) - await waitForEviction() + await host.close(SESSION) + expect(host.hasSession(SESSION)).toBe(false) - await host.hold(SESSION, 'desktop-chat:2') + await startAgent() const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'subscriber-1', sessionId: SESSION, emit: (event) => events.push(event) @@ -492,10 +479,10 @@ describe('a session evicted and opened again', () => { }) }) -function submissionState(clientMessageId: string): string | undefined { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId)?.dispatchState +async function submissionState(clientMessageId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + )?.dispatchState } describe('an unexpected provider exit', () => { @@ -539,7 +526,7 @@ describe('an unexpected provider exit', () => { await failJournalSinkUntilReleased() expect(dispatch).not.toHaveBeenCalled() - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect( history.ok && history.page.items.some( @@ -561,9 +548,8 @@ describe('an unexpected provider exit', () => { replaceFailedSink() }) - it('releases the exact generation, reacquires outside the queue, and dispatches a new message', async () => { + it('releases the exact generation, starts nothing, and the next message starts a child', async () => { await attach() - await host.hold(SESSION, SURFACE) dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) const unknownBody = hostTestMessage('message with unknown delivery') const unknownEnvelope = envelope('agentSession.send', { body: unknownBody }) @@ -571,8 +557,8 @@ describe('an unexpected provider exit', () => { host.send(CALLER, { envelope: unknownEnvelope, body: unknownBody }) ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) // Accepted, then handed over by the delivery loop, where the thrown dispatch becomes doubt. - await vi.waitFor(() => - expect(submissionState(unknownEnvelope.clientOperationId)).toBe('unknown') + await vi.waitFor(async () => + expect(await submissionState(unknownEnvelope.clientOperationId)).toBe('unknown') ) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -585,17 +571,17 @@ describe('an unexpected provider exit', () => { acquisitionGeneration: 'generation-1' }) - const recoveredHistory = host.history({ sessionId: SESSION, direction: 'tail' }) + const recoveredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) expect( recoveredHistory.ok && hasUnansweredStructuredAgentSessionDispatch(recoveredHistory.page.submissions) ).toBe(false) - expect(acquire).toHaveBeenCalledTimes(2) + // No respawn: the exit is settled and shown, and the conversation waits for work. + expect(acquire).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeFence: exitedFence + 2, - ownerProcess: { pid: 4242 } + claimStatus: 'released', + runtimeFence: exitedFence + 1 }) dispatch.mockResolvedValueOnce({ state: 'accepted', @@ -607,13 +593,20 @@ describe('an unexpected provider exit', () => { ok: true, value: { submission: { dispatchState: 'pending' } } }) - await vi.waitFor(() => expect(submissionState(nextEnvelope.clientOperationId)).toBe('accepted')) + await vi.waitFor(async () => + expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') + ) + expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledTimes(2) }) - it('does not reacquire for a subscription-only hold or a stale child generation', async () => { + it('does not reacquire for a live reader or a stale child generation', async () => { await attach() - await host.hold(SESSION, 'subscriber-1', { resume: false }) + const unsubscribe = await host.subscribe({ + id: 'subscriber-1', + sessionId: SESSION, + emit: () => undefined + }) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -641,11 +634,11 @@ describe('an unexpected provider exit', () => { runtimeFence: exitedFence + 1, deathEvidence: { kind: 'exit-observed' } }) + unsubscribe() }) it('keeps a requested close out of recovery', async () => { await attach() - await host.hold(SESSION, SURFACE) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await host.handleAdapterEvent({ @@ -663,7 +656,6 @@ describe('an unexpected provider exit', () => { it('recovers after a failed lifecycle barrier and dispatches a distinct next message', async () => { await attach() - await host.hold(SESSION, SURFACE) dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) const unknownBody = hostTestMessage('message with unknown delivery') const unknownParams = { @@ -674,8 +666,8 @@ describe('an unexpected provider exit', () => { ok: true, value: { submission: { dispatchState: 'pending' } } }) - await vi.waitFor(() => - expect(submissionState(unknownParams.envelope.clientOperationId)).toBe('unknown') + await vi.waitFor(async () => + expect(await submissionState(unknownParams.envelope.clientOperationId)).toBe('unknown') ) const runtimeState = ( host as unknown as { @@ -698,21 +690,20 @@ describe('an unexpected provider exit', () => { }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeFence: exitedFence + 2, - ownerProcess: { pid: 4242 } + claimStatus: 'released', + runtimeFence: exitedFence + 1 }) - expect(acquire).toHaveBeenCalledTimes(2) + expect(acquire).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() expect(hostErrors).toContainEqual(expect.objectContaining({ message: 'journal failed' })) - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is - // written — carrying the cause, and never the old bare `Provider exited: ` row. + // written — its failure fact beside it, and never the old bare `Provider exited: ` row. const statuses = history.ok ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) : [] - expect(statuses).toEqual([unexpectedProviderExitOutcome('provider exited')]) + expect(statuses).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) expect(statuses.some((text) => text.startsWith('Provider exited'))).toBe(false) dispatch.mockResolvedValueOnce({ @@ -725,13 +716,14 @@ describe('an unexpected provider exit', () => { ok: true, value: { submission: { dispatchState: 'pending' } } }) - await vi.waitFor(() => expect(submissionState(nextEnvelope.clientOperationId)).toBe('accepted')) + await vi.waitFor(async () => + expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') + ) expect(dispatch).toHaveBeenCalledTimes(2) }) it('releases the lease when the exit settlement cannot be written, and the next send settles the turn it left', async () => { await attach() - await host.hold(SESSION, SURFACE) emitTurnLifecycle('running', 1) await host.flushStreamedEvents(SESSION) const runtimeState = ( @@ -788,18 +780,24 @@ describe('an unexpected provider exit', () => { ok: true, value: { submission: { dispatchState: 'pending' } } }) - await vi.waitFor(() => expect(submissionState(sentEnvelope.clientOperationId)).toBe('accepted')) + await vi.waitFor(async () => + expect(await submissionState(sentEnvelope.clientOperationId)).toBe('accepted') + ) expect(acquire).toHaveBeenCalledTimes(2) // The new child's acquire settled the turn from the release's evidence: ended at the exit's - // receipt, with the exit's own reason in the row. - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + // receipt. The evidence is Orca's log text, so the row says only that the provider stopped. + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) const items = history.ok ? history.page.items : [] expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', completedAt: NOW }) ) - expect( - items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toContain(unexpectedProviderExitOutcome('provider exited')) + const statuses = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + expect(statuses).toContainEqual({ + kind: 'status', + text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, + failure: { kind: 'providerExited' } + }) + 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 3d4e8491986..f490eb2e9cd 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 @@ -125,7 +125,11 @@ describe('performThreadGoalChange', () => { expect(result).toEqual({ ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'goals feature is disabled' } + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'providerRejected' }, + message: 'goals feature is disabled' + } }) expect(journal.snapshot().items).toEqual([]) }) 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 3f63679d55f..639daf6ab30 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 @@ -1,6 +1,7 @@ // `agentSession.threadGoal`: change the provider thread's goal through the same // admission, ledger and journal path every other session mutation takes. +import { refuse, type AgentSessionRefusalReason } from '../../../shared/agent-session-wire-refusals' import type { AgentJournalItemIdentity, AgentJournalThreadGoal @@ -13,8 +14,11 @@ import type { import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -function refused(message: string): TurnOutcome { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function refused( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): TurnOutcome { + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** Keyed by the operation, so a replayed set upserts its one objective row. */ @@ -44,7 +48,7 @@ export async function performThreadGoalChange( input: { clientOperationId: string; change: AgentSessionThreadGoalChange } ): Promise> { if (!ctx.adapter.changeThreadGoal || !ctx.adapter.supportsThreadGoal?.(ctx.sessionId)) { - return refused('Goals are unavailable for this chat session.') + return refused('goalsUnsupported', 'Goals are unavailable for this chat session.') } const { change } = input const identity = objectiveIdentity(input.clientOperationId) @@ -89,7 +93,7 @@ export async function performThreadGoalChange( } if (!result.ok) { await withdrawObjective() - return refused(result.rejected) + return refused('providerRejected', result.rejected) } return { ok: true, value: { change: change.kind } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts index cb1685405a1..9230e56cbe8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts @@ -1,4 +1,4 @@ -import type { AgentSessionOptionResult } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionOptionResult } from '../../../shared/agent-session-wire' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -17,7 +17,11 @@ export async function performSetOption( if (isAgentSessionOptionRejectedError(error)) { return { ok: false, - refusal: { code: 'agent_session_operation_invalid', message: error.message } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: error.refusalReason }, + error.message + ) } } throw error 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 e235ccb4940..f10b64be1db 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 @@ -1,3 +1,5 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { agentSessionPromptQuestions, @@ -12,7 +14,11 @@ import type { AgentJournalQuestionItem, AgentJournalResolution } from '../../../shared/agent-session-journal-types' -import type { AgentSessionPromptResult } from '../../../shared/agent-session-wire' +import { + refuse, + type AgentSessionPromptResult, + type AgentSessionRefusalReason +} from '../../../shared/agent-session-wire' import { AgentSessionPromptAnswerRejectedError, AgentSessionPromptUnavailableError @@ -29,8 +35,11 @@ export type AgentSessionPromptRequest = { answers?: AgentSessionQuestionAnswer[] } -function invalid(message: string): TurnOutcome { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function invalid( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): TurnOutcome { + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** The one place a client's choice is read; an answer an older client packed into `optionId` is unpacked here, once. */ @@ -73,6 +82,7 @@ export async function performPrompt( const choice = readPromptChoice(prompt, input) if (!choice) { return invalid( + 'optionRejected', input.optionId !== undefined ? `Option ${input.optionId} is not offered by item ${input.itemId}.` : `The answers do not match the questions on item ${input.itemId}.` @@ -81,7 +91,7 @@ export async function performPrompt( const { response } = choice const identity = parseAgentJournalItemKey(input.itemId) if (!identity) { - return invalid(`Item id ${input.itemId} is not a well-formed item key.`) + return invalid('requestMalformed', `Item id ${input.itemId} is not a well-formed item key.`) } const resolution: AgentJournalResolution = { @@ -110,23 +120,23 @@ export async function performPrompt( } }) } catch (error) { - if ( - !committed.item && - (error instanceof AgentSessionPromptUnavailableError || - error instanceof AgentSessionPromptAnswerRejectedError) - ) { - return invalid(error.message) + if (!committed.item && error instanceof AgentSessionPromptUnavailableError) { + return invalid('promptGone', error.message) + } + if (!committed.item && error instanceof AgentSessionPromptAnswerRejectedError) { + return invalid('optionRejected', error.message) } if (!committed.item) { throw error } + // The adapter's error is Orca's; the row says only what the user needs to know. await ctx.journal.appendItem( { provider: 'orca', clientMessageId: `${input.itemId}#delivery` }, { kind: 'status', - text: `Your answer was recorded but the agent did not confirm it: ${ - error instanceof Error ? error.message : String(error) - }` + ...agentSessionFailureWords(agentSessionFailureFact('answerUnconfirmed'), { + surface: 'row' + }) }, { fence: ctx.fence } ) 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 5eac140868f..49642b9a897 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 @@ -6,14 +6,22 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, + AgentJournalStatusItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionCancelResult, - AgentSessionSendResult, - AgentSessionWireRefusal +import { + refuse, + type AgentSessionCancelResult, + type AgentSessionRefusalReason, + type AgentSessionSendResult, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -23,7 +31,7 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' -import { providerStartupFailureRejection } from './structured-agent-session-dead-generation-settlement' +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' export { performSetOption } from './structured-agent-session-turns-options' @@ -52,8 +60,11 @@ export type TurnOutcome = | { ok: true; value: TValue } | { ok: false; refusal: AgentSessionWireRefusal } -function invalid(message: string): { ok: false; refusal: AgentSessionWireRefusal } { - return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +function invalid( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): { ok: false; refusal: AgentSessionWireRefusal } { + return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` @@ -76,7 +87,10 @@ async function dispatchSafely( }) } catch (error) { if (ctx.providerChildPhase?.() === 'starting') { - return { state: 'rejected', reason: providerStartupFailureRejection(error) } + return { + state: 'rejected', + ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) + } } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } @@ -85,13 +99,9 @@ async function dispatchSafely( async function appendStatus( ctx: AgentSessionTurnContext, clientMessageId: string, - text: string + body: AgentJournalStatusItem ): Promise { - await ctx.journal.appendItem( - { provider: 'orca', clientMessageId }, - { kind: 'status', text }, - { fence: ctx.fence } - ) + await ctx.journal.appendItem({ provider: 'orca', clientMessageId }, body, { fence: ctx.fence }) } /** @@ -116,7 +126,10 @@ export async function performSend( .submissions() .find((entry) => entry.clientMessageId === input.clientMessageId) if (existing && existing.payloadFingerprint !== input.payloadFingerprint) { - return invalid(`Message id ${input.clientMessageId} was already used for another send.`) + return invalid( + 'messageIdReused', + `Message id ${input.clientMessageId} was already used for another send.` + ) } if (existing) { return { @@ -127,7 +140,7 @@ export async function performSend( try { await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) } catch { - return invalid('The message could not be recorded and was not sent.') + return invalid('journalWriteFailed', 'The message could not be recorded and was not sent.') } return { ok: true, @@ -141,7 +154,10 @@ 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 +} /** * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash @@ -157,7 +173,7 @@ export async function handOverSubmission( await ctx.journal.resolveDispatch({ clientMessageId, state: 'rejected', - reason: 'The message could not be read back and was not sent.', + ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { surface: 'rejection' }), fence: ctx.fence }) return @@ -179,7 +195,15 @@ export async function handOverSubmission( providerIdentity: outcome.providerIdentity, fence: ctx.fence } - : { clientMessageId, state: outcome.state, reason: outcome.reason, fence: ctx.fence } + : outcome.state === 'rejected' + ? { + clientMessageId, + state: 'rejected', + reason: outcome.reason, + rejection: outcome.rejection, + fence: ctx.fence + } + : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } ) } catch (error) { // A failed resolution must not strand a pending row; an unknown result is @@ -228,7 +252,7 @@ export async function performCancel( } } let cancelled = false - let note = 'Cancellation requested.' + let note: AgentJournalStatusItem = { kind: 'status', text: 'Cancellation requested.' } try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) cancelled = input.scope @@ -251,15 +275,17 @@ export async function performCancel( }) ).cancelled if (!cancelled) { - note = 'The provider had already finished this turn.' + note = { kind: 'status', text: 'The provider had already finished this turn.' } } } catch (error) { if (input.prompt) { throw error } - note = `Cancellation was not confirmed: ${ - error instanceof Error ? error.message : String(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() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts index 952fa70657d..9a27415dc71 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection' -import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-host-mutations' +import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' const FENCE = 7 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts new file mode 100644 index 00000000000..713838c177e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts @@ -0,0 +1,43 @@ +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' + +/** + * Releases sends the provider can no longer be holding. + * + * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of + * non-delivery — and a live `unknown` reads as work still owed, so the session + * shows working until something re-derives it. The provider reporting its thread + * not running, with no turn open, IS that re-derivation. + * + * `pending` is deliberately untouched: that send's dispatch has not returned yet + * and may be in flight right now. And `recovered` only retires the obligation — + * it never makes a send re-deliverable, because the provider may well have run it. + */ +export async function releaseStructuredAgentSessionUnansweredDispatches( + context: Pick & { + deps: { store: Pick } + }, + input: { sessionId: string; reason: string } +): Promise { + const session = context.sessions.get(input.sessionId) + if (!session) { + return + } + const stranded = session.journal + .submissions() + .filter((entry) => entry.dispatchState === 'unknown' && entry.recovered !== true) + if (stranded.length === 0) { + return + } + for (const entry of stranded) { + await session.journal.resolveDispatch({ + clientMessageId: entry.clientMessageId, + state: 'unknown', + // The earlier reason names a sharper fact than this one does. + reason: entry.reason ?? input.reason, + fence: structuredAgentSessionConversationFence(context.deps.store, input.sessionId), + recovered: true + }) + } +} 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 6419e1cfb1a..0685af60549 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 @@ -7,59 +7,19 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { - settleStaleStructuredAgentSessionState, - unexpectedProviderExitOutcome -} from './structured-agent-session-dead-generation-settlement' -import { - isStructuredAgentSessionRecoveryTicketCurrent, settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, - type StructuredAgentSessionUnexpectedExitSession, - type StructuredAgentSessionRecoveryTicket + type StructuredAgentSessionUnexpectedExitSession } from './structured-agent-session-unexpected-exit' +const exitOutcome = (agent: string): string => + `${agent} stopped while this response was in progress. You can continue in this conversation.` + const SESSION = 'session-1' const GENERATION = 'generation-1' -const ticket: StructuredAgentSessionRecoveryTicket = { - sessionId: SESSION, - releasedFence: 8, - deadAcquisitionGeneration: GENERATION, - stableSettlementId: 'settlement-1' -} - -function recoveryContext(input: { - generation?: string - handoffStage?: AgentSessionRecord['lease']['handoffStage'] - resumeCapable?: boolean -}) { - const session: Pick = { - child: null, - lastEndedChild: { - generation: input.generation ?? GENERATION, - fence: 7, - cause: 'exit', - reason: null, - duringStartup: false, - rootGone: true, - endedAt: { epoch: 'epoch-1', sequence: 0 } - } - } - const record = { - lease: { - runtimeFence: 8, - claimStatus: 'released', - handoffStage: input.handoffStage ?? null - } - } as AgentSessionRecord - return { - sessions: new Map([[SESSION, session]]), - store: { getRecord: () => record }, - hasResumeCapableHolder: () => input.resumeCapable ?? true - } as never -} - function lifecycleItem( turnId: string, sequence: number, @@ -111,7 +71,7 @@ function mutableStore() { } } -describe('provider-exit recovery tickets', () => { +describe('provider-exit settlement', () => { it.each([undefined, 2_000])('keeps exit receipt %s when settling fails', async (observedAt) => { let now = observedAt === undefined ? 2_000 : 30_000 let record = agentSessionRecordFixture( @@ -150,7 +110,6 @@ describe('provider-exit recovery tickets', () => { return { ok: false, error: new Error('sink unavailable') } }, publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId, task) => task(), now: () => now } as never, @@ -224,13 +183,12 @@ describe('provider-exit recovery tickets', () => { transitionHandoff: async () => ({ lease: { runtimeFence: 8 } }) } - const result = await settleUnexpectedStructuredAgentSessionExit( + await settleUnexpectedStructuredAgentSessionExit( { store, sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId, task) => task(), now: () => 1_234 } as never, @@ -244,7 +202,6 @@ describe('provider-exit recovery tickets', () => { } ) - expect(result).toMatchObject({ releasedFence: 8 }) expect(session.journal.markPendingSubmissionsUnknown).toHaveBeenCalledWith( 7, 'provider_exited_before_acknowledgement' @@ -262,7 +219,11 @@ describe('provider-exit recovery tickets', () => { provider: 'orca', clientMessageId: `provider-exit:${SESSION}:7:${GENERATION}` }, - body: { kind: 'status', text: unexpectedProviderExitOutcome('provider exited') } + body: { + kind: 'status', + text: exitOutcome('The agent'), + failure: { kind: 'providerExited' } + } }, { kind: 'item', @@ -331,7 +292,6 @@ describe('provider-exit recovery tickets', () => { return { ok: true } }, publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1_234 } @@ -351,7 +311,8 @@ describe('provider-exit recovery tickets', () => { expect.objectContaining({ body: { kind: 'status', - text: unexpectedProviderExitOutcome('provider exited after completing the turn') + text: exitOutcome('Claude'), + failure: { kind: 'providerExited' } } }) ]) @@ -379,7 +340,6 @@ describe('provider-exit recovery tickets', () => { sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: true }), publishFence: vi.fn(), - hasResumeCapableHolder: () => true, serialize: async (_sessionId: string, task: () => Promise) => task(), now: () => 1 } @@ -400,7 +360,11 @@ describe('provider-exit recovery tickets', () => { expect.objectContaining({ mutations: [ expect.objectContaining({ - body: { kind: 'status', text: unexpectedProviderExitOutcome('provider exited') } + body: { + kind: 'status', + text: exitOutcome('Claude'), + failure: { kind: 'providerExited' } + } }) ] }) @@ -438,44 +402,14 @@ describe('provider-exit recovery tickets', () => { sessions: new Map([[SESSION, session]]), flushLifecycle: async () => ({ ok: false, error: new Error('sink failed') }), publishFence, - hasResumeCapableHolder: () => true, serialize: async (_sessionId, task) => task(), now: () => 1, onBarrierError: release } - const result = await settleUnexpectedStructuredAgentSessionExit(context, event) + await settleUnexpectedStructuredAgentSessionExit(context, event) - expect(result).toBeNull() expect(session.child).toBeNull() expect(publishFence).toHaveBeenCalledTimes(1) expect(release).toHaveBeenCalledTimes(2) }) - - it('admits the exact released generation for a resume-capable holder', () => { - expect(isStructuredAgentSessionRecoveryTicketCurrent(recoveryContext({}), ticket)).toBe(true) - }) - - it('is cancelled by a latched stage before reattachment', () => { - expect( - isStructuredAgentSessionRecoveryTicketCurrent( - recoveryContext({ handoffStage: 'recovering' }), - ticket - ) - ).toBe(false) - }) - - it('is cancelled when its holder or dead acquisition generation is no longer current', () => { - expect( - isStructuredAgentSessionRecoveryTicketCurrent( - recoveryContext({ resumeCapable: false }), - ticket - ) - ).toBe(false) - expect( - isStructuredAgentSessionRecoveryTicketCurrent( - recoveryContext({ generation: 'generation-new' }), - ticket - ) - ).toBe(false) - }) }) 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 541daec0153..3a47d1240f2 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 @@ -1,10 +1,9 @@ +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { - endProviderChild, - failedProviderChildStart -} from './structured-agent-session-provider-child' +import { endProviderChild } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit, type StructuredAgentSessionLeaseStore @@ -23,13 +22,6 @@ type UnexpectedExitLifecycleEvent = StructuredAgentSessionEndedEvent & { cause: 'unexpected-exit' } -export type StructuredAgentSessionRecoveryTicket = { - sessionId: string - releasedFence: number - deadAcquisitionGeneration: string - stableSettlementId: string -} - export type StructuredAgentSessionUnexpectedExitSession = Pick< StructuredAgentSessionHostSession, 'child' | 'lastEndedChild' @@ -43,7 +35,6 @@ export type StructuredAgentSessionUnexpectedExitContext< flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: TSession) => void publishStatus?: (sessionId: string) => void - hasResumeCapableHolder: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise now: () => number onBarrierError?: (sessionId: string, error: unknown) => void @@ -54,9 +45,9 @@ export async function settleUnexpectedStructuredAgentSessionExit< >( context: StructuredAgentSessionUnexpectedExitContext, event: StructuredAgentSessionEndedEvent -): Promise { +): Promise { if (event.cause !== 'unexpected-exit') { - return null + return } const unexpectedEvent = event as UnexpectedExitLifecycleEvent // Receipt of the exit is the one end time the host may record for a running turn. @@ -70,10 +61,10 @@ export async function settleUnexpectedStructuredAgentSessionExit< child.fence !== unexpectedEvent.fence || child.generation !== unexpectedEvent.acquisitionGeneration ) { - return null + return } // The host's own phase decides, so a provider that omits the flag still gets a start that - // failed told as one: the row says so, and nothing resumes into the same failure. + // failed told as one: the row says so. const exitedDuringStartup = unexpectedEvent.startupUnproven === true || child.phase === 'starting' const endChild = (): void => { @@ -82,6 +73,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< fence: child.fence, cause: 'exit', reason: unexpectedEvent.reason, + ...(unexpectedEvent.failure ? { failure: unexpectedEvent.failure } : {}), duringStartup: exitedDuringStartup, // The adapter publishes an exit only once it saw the root go, first-hand or proven. rootGone: true @@ -92,15 +84,11 @@ export async function settleUnexpectedStructuredAgentSessionExit< if (!record || record.lease.handoffStage !== null) { // An acquisition or recovery already owns this lease's transition. endChild() - return null + return } - let settlementFailed = false const stableSettlementId = providerExitSettlementId(unexpectedEvent) const unfinishedWork = captureUnfinishedStructuredAgentSessionWork(session.journal) - let released: Awaited< - ReturnType - > | null = null try { try { const barrier = await context.flushLifecycle(unexpectedEvent.sessionId) @@ -110,7 +98,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< } catch (error) { context.onBarrierError?.(unexpectedEvent.sessionId, error) } - settlementFailed = !(await retryUnexpectedExitSettlement({ + await retryUnexpectedExitSettlement({ context, event: unexpectedEvent, journal: session.journal, @@ -118,6 +106,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< stableSettlementId, verdict: { state: 'interrupted', completedAt: observedAt }, exitedDuringStartup, + failureTextContext: structuredAgentSessionFailureWordsContext(record), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: exitedDuringStartup || @@ -126,10 +115,13 @@ export async function settleUnexpectedStructuredAgentSessionExit< session.journal, observedAt ) - })) + }) } finally { // Provider exit was positively observed, so release the owner even when // terminal settlement could not be durably accepted. + let released: Awaited< + ReturnType + > | null = null try { released = await releaseStoredStructuredAgentSessionOwnerAfterUnexpectedExit({ store: context.store, @@ -152,48 +144,9 @@ export async function settleUnexpectedStructuredAgentSessionExit< } } } - if (settlementFailed || !released) { - return null - } - if ( - failedProviderChildStart(session) || - !context.hasResumeCapableHolder(unexpectedEvent.sessionId) - ) { - return null - } - return { - sessionId: unexpectedEvent.sessionId, - releasedFence: released.lease.runtimeFence, - deadAcquisitionGeneration: unexpectedEvent.acquisitionGeneration, - stableSettlementId - } }) } -export function isStructuredAgentSessionRecoveryTicketCurrent( - context: { - store: Pick - sessions: Map< - string, - Pick - > - hasResumeCapableHolder: (sessionId: string) => boolean - }, - ticket: StructuredAgentSessionRecoveryTicket -): boolean { - const session = context.sessions.get(ticket.sessionId) - const record = context.store.getRecord(ticket.sessionId) - return ( - session !== undefined && - session.child === null && - session.lastEndedChild?.generation === ticket.deadAcquisitionGeneration && - record?.lease.runtimeFence === ticket.releasedFence && - record.lease.claimStatus === 'released' && - record.lease.handoffStage === null && - context.hasResumeCapableHolder(ticket.sessionId) - ) -} - async function retryUnexpectedExitSettlement(input: { context: Pick event: UnexpectedExitLifecycleEvent @@ -202,6 +155,7 @@ async function retryUnexpectedExitSettlement(input: { stableSettlementId: string verdict: StructuredAgentSessionTurnVerdict exitedDuringStartup: boolean + failureTextContext: AgentSessionFailureWordsContext showUnexpectedExitOutcome?: boolean }): Promise { return settleStructuredAgentSessionDeadGeneration({ @@ -212,7 +166,8 @@ async function retryUnexpectedExitSettlement(input: { verdict: input.verdict, pendingSubmissionReason: 'provider_exited_before_acknowledgement', showUnexpectedExitOutcome: input.showUnexpectedExitOutcome, - unexpectedExitReason: input.event.reason, + ...(input.event.failure ? { exitFailure: input.event.failure } : {}), + failureTextContext: input.failureTextContext, ...(input.exitedDuringStartup ? { exitedDuringStartup: { generation: input.event.acquisitionGeneration } } : {}), 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 b8e2d73c27f..957ac2f275d 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 @@ -102,22 +102,25 @@ async function settleExits(): Promise { await eventually(async () => { await adapter.drainObservedExits() await Promise.all(lifecycle) - expect(host.journalSnapshot(SESSION).items.length).toBeGreaterThan(0) + expect((await host.journalSnapshot(SESSION)).items.length).toBeGreaterThan(0) }) await Promise.all(lifecycle) } +/** A view of the chat: a subscription, which reads the chat and starts nothing. */ +function view(id: string): Promise<() => void> { + return host.subscribe({ id, sessionId: SESSION, emit: () => undefined }) +} + /** The chat as it renders: the user's messages and the error rows, in journal order. */ -function timeline(): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => - item.body.kind === 'message' - ? ['message'] - : item.body.kind === 'status' && item.body.tone === 'error' - ? [item.body.text] - : [] - ) +async function timeline(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'message' + ? ['message'] + : item.body.kind === 'status' && item.body.tone === 'error' + ? [item.body.text] + : [] + ) } async function send(text: string): Promise { @@ -171,37 +174,39 @@ describe('a fresh chat whose Claude start fails', () => { if (!createStillStarting) { await settleExits() } - // Two surfaces bind, as a pane and a second window do; exit recovery sees a holder. - await host.hold(SESSION, SURFACE) - await host.hold(SESSION, 'desktop-chat:2') + // Two surfaces bind, as a pane and a second window do. + const unsubscribe = await view(SURFACE) + await view('desktop-chat:2') if (createStillStarting) { // The views bound to the create's child itself, before it exited. - expect(timeline()).toEqual([]) + expect(await timeline()).toEqual([]) releaseCreate() } await settleExits() - const startFailure = `The provider stopped before it finished starting: ${LAUNCH_FAILURE}.` + // The row says the provider stopped; its stderr stays out of the sentence. + const startFailure = + 'Claude stopped before it finished starting. Send your message to try again.' // Opening the chat: the create's start, once, and its row. expect(claude.connections).toHaveLength(1) - expect(timeline()).toEqual([startFailure]) + expect(await timeline()).toEqual([startFailure]) const sent = await send('reply with exactly: alpha') - await eventually(() => + await eventually(async () => expect( - host.journalSnapshot(SESSION).submissions.find((s) => s.clientMessageId === sent) + (await host.journalSnapshot(SESSION)).submissions.find((s) => s.clientMessageId === sent) ).toMatchObject({ dispatchState: 'rejected', reason: startFailure }) ) await settleExits() // The send's own start, once, and one row for it below the message. expect(claude.connections).toHaveLength(2) - expect(timeline()).toEqual([startFailure, 'message', startFailure]) + expect(await timeline()).toEqual([startFailure, 'message', startFailure]) - // Switching away and back re-takes the view's hold; it starts nothing and adds no row. - host.release(SESSION, SURFACE) - await host.hold(SESSION, SURFACE) + // Switching away and back re-subscribes; it starts nothing and adds no row. + unsubscribe() + await view(SURFACE) await settleExits() expect(claude.connections).toHaveLength(2) - expect(timeline()).toEqual([startFailure, 'message', startFailure]) + expect(await timeline()).toEqual([startFailure, 'message', startFailure]) } ) }) 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 66e3cd2cca2..98b212a8606 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 @@ -6,9 +6,9 @@ // conversation — the journal, the provider handle chain, and the recorded evidence all survive. // // "Usable" means ACQUIRABLE, not acquired. Startup no longer resumes a provider child for a record -// nobody is looking at; a surface taking a hold is what spawns one. So the migration's job is to -// leave the lease in a state a hold can claim, and these tests prove that by adjudicating it rather -// than by reading fields off it. +// nobody is looking at; work that needs the agent is what spawns one. So the migration's job is to +// 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 { tmpdir } from 'node:os' @@ -149,6 +149,11 @@ function openHost(overrides: Partial = {}): void }) } +/** The host starting the agent with no message to deliver, as an operation that needs it does. */ +function startAgent(): Promise { + return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) +} + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wedged-profile-')) resetHostTestOperationIds() @@ -329,14 +334,19 @@ describe('already-wedged profiles become usable on load', () => { } ) - it('settles restart eviction through attach when a hold arrives before the boot sweep', async () => { + it('settles restart eviction through attach when a start arrives before the boot sweep', async () => { await seedStore( wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }) ) await seedRunningTurn() openHost() - await host.hold(SESSION, 'desktop-chat:restart') + // The attach adjudicates the dead owner first, which moves the fence; like a client's ensure, + // it is retried at the fence the refusal names. + const stale = await host.attach(CALLER, hostTestAttachParams(13)) + const fence = stale.ok ? 13 : (stale.refusal.currentFence ?? 13) + expect(stale.ok || stale.refusal.code === 'agent_session_checkpoint_stale').toBe(true) + expect(stale.ok || (await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -429,7 +439,7 @@ describe('already-wedged profiles become usable on load', () => { deathEvidence: { kind: 'pid-absent' } }) - await host.hold(SESSION, 'desktop-chat:restart') + await startAgent() expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live' }) @@ -653,7 +663,8 @@ describe('already-wedged profiles become usable on load', () => { await host.restoreReadableSessions() expect(order).toEqual([]) expect(scan).not.toHaveBeenCalled() - await host.hold(SESSION, 'holder-1') + const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) expect(order).toEqual(['acquire']) }) @@ -679,9 +690,9 @@ describe('already-wedged profiles become usable on load', () => { }) expect(acquire).not.toHaveBeenCalled() - // The user quits that terminal: the next open proves it gone and the chat takes over. + // The user quits that terminal: the next start proves it gone and the chat takes over. probe = { outcome: 'pid-absent' } - await host.hold(SESSION, 'holder-1') + await startAgent() expect(acquire).toHaveBeenCalledOnce() expect(stopOwnerProcess).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index e68df2c09a6..366ce1cf948 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -192,6 +192,7 @@ export function structuredAgentSessionWorkingAtStop(input: { recordedAt: input.now, trigger: input.trigger, teardownId: input.teardownId, + journalCursor: snapshot.cursor, // Root, not key: the close path advances Claude's leaf moments after this runs, and a key // comparison would then refuse the session forever. providerHandleRoot: agentSessionProviderHandleRoot(head.handle), 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 new file mode 100644 index 00000000000..4c565bcf4e2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts @@ -0,0 +1,48 @@ +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 index 64f1ee652e7..b581d746f62 100644 --- a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts @@ -1,3 +1,5 @@ +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' @@ -26,13 +28,21 @@ export async function settleInterruptedCompaction( if (command?.command !== 'compact' || command.phase !== 'prepared') { return } - const error = 'Previous compaction completion could not be confirmed after session recovery.' + const words = agentSessionFailureWords(agentSessionFailureFact('compactionUnconfirmed'), { + surface: 'row' + }) await journal.appendItem( { provider: 'orca', clientMessageId: `compact:${command.operationId}` }, - { kind: 'status', text: error }, + { kind: 'status', ...words }, { fence } ) - const recovered = { ...command, phase: 'committed' as const, state: 'unknown' as const, error } + 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, 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 5a3370c910f..fe3e443c495 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 @@ -29,12 +29,12 @@ describe('conversationCommandBlocked background tasks', () => { contextWith({ state: 'monitoring', supportsTaskStop: true }), RECORD ) - expect(blocked).toBe('Stop background tasks before using this command.') + expect(blocked?.message).toBe('Stop background tasks before using this command.') }) it('asks for a stop on a host that predates the stop-capability field', () => { const blocked = conversationCommandBlocked(contextWith({ state: 'monitoring' }), RECORD) - expect(blocked).toBe('Stop background tasks before using this command.') + expect(blocked?.message).toBe('Stop background tasks before using this command.') }) it('asks the user to wait when the provider exposes no stop at all', () => { @@ -43,7 +43,7 @@ describe('conversationCommandBlocked background tasks', () => { contextWith({ state: 'monitoring', supportsStopAll: false }), RECORD ) - expect(blocked).toBe('Wait for background tasks to finish before using this command.') + expect(blocked?.message).toBe('Wait for background tasks to finish before using this command.') }) it('still refuses on the open turn, not on the work the strip now shows', () => { @@ -63,7 +63,7 @@ describe('conversationCommandBlocked background tasks', () => { } ] }) as unknown as ReturnType - expect(conversationCommandBlocked(ctx, RECORD)).toBe( + expect(conversationCommandBlocked(ctx, RECORD)?.message).toBe( 'Wait for the current turn to finish before using this command.' ) }) 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 fe6e5501975..cecdbbf0b13 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,33 +1,51 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { + refuse, + type AgentSessionRefusalReason, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire-refusals' + +function blocked( + reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, + message: string +): AgentSessionWireRefusal { + return refuse('agent_session_operation_invalid', { reason }, message) +} export function conversationCommandBlocked( ctx: AgentSessionTurnContext, record: AgentSessionRecord -): string | null { +): AgentSessionWireRefusal | null { const items = ctx.journal.snapshot().items if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') { - return 'agent_session_rewind:outcome-unknown' + return blocked('rewindUnconfirmed', 'agent_session_rewind:outcome-unknown') } if ( record.conversationCommand?.command === 'clear' && record.conversationCommand.phase === 'committed' && record.conversationCommand.replacementSessionId ) { - return 'This conversation has been cleared. Open the current conversation to continue.' + return blocked( + 'conversationCleared', + 'This conversation has been cleared. Open the current conversation to continue.' + ) } if ( record.conversationCommand?.state === 'unknown' && record.conversationCommand.phase === 'prepared' ) { - return 'The previous conversation operation is unconfirmed.' + return blocked( + 'conversationCommandUnconfirmed', + 'The previous conversation operation is unconfirmed.' + ) } if (record.lease.handoffStage || record.lease.handoffOperationId) { - return 'Wait for the session handoff to finish.' + return blocked('handoffInFlight', 'Wait for the session handoff to finish.') } if (activeStructuredAgentSessionTurnId(items)) { - return 'Wait for the current turn to finish before using this command.' + return blocked('turnActive', 'Wait for the current turn to finish before using this command.') } if ( items.some( @@ -36,16 +54,22 @@ export function conversationCommandBlocked( item.body.resolution.state === 'pending' ) ) { - return 'Resolve the pending question or approval before using this command.' + return blocked( + 'promptPending', + 'Resolve the pending question or approval before using this command.' + ) } const backgroundTasks = ctx.adapter.backgroundTaskState?.(ctx.sessionId) if (backgroundTasks?.state === 'monitoring') { // Only ask for a stop the host can actually perform. A provider that // exposes neither a targeted nor an untargeted stop would otherwise leave // the command refused behind an instruction nobody can follow. - return backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false - ? 'Stop background tasks before using this command.' - : 'Wait for background tasks to finish before using this command.' + return blocked( + 'backgroundTasksRunning', + backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false + ? 'Stop background tasks before using this command.' + : 'Wait for background tasks to finish before using this command.' + ) } if ( ctx.journal.submissions().some( @@ -55,7 +79,10 @@ export function conversationCommandBlocked( (entry.dispatchState === 'unknown' && entry.recovered !== true && entry.fence === ctx.fence) ) ) { - return 'Resolve pending or unconfirmed messages before using this command.' + return blocked( + 'messagesUnsettled', + 'Resolve pending or unconfirmed messages before using this command.' + ) } return null } 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 e5a0283ba83..662ec70e8dd 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,3 +1,4 @@ +import { refuse } from '../../../shared/agent-session-wire-refusals' import { sendStructuredAgentSessionTurn } from './structured-agent-session-host-mutations' import { runStructuredConversationCommand, @@ -20,10 +21,11 @@ export class StructuredConversationCommandController { this.pending.has(params.envelope.sessionId) ? Promise.resolve({ ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: 'Wait for the conversation operation to finish.' - } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCommandInFlight' }, + 'Wait for the conversation operation to finish.' + ) }) : sendStructuredAgentSessionTurn(this.context(), caller, params) @@ -33,10 +35,11 @@ export class StructuredConversationCommandController { if (pending && pending.key !== key) { return Promise.resolve({ ok: false as const, - refusal: { - code: 'agent_session_operation_invalid' as const, - message: 'Wait for the conversation operation to finish.' - } + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'conversationCommandInFlight' }, + 'Wait for the conversation operation to finish.' + ) }) } const entry = pending ?? { key, count: 0 } 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 37d8d4ee83d..445326f60b0 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 @@ -6,7 +6,11 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + AgentSessionAcquisitionRefusal, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import type { StructuredSessionCompactionResult } from './structured-session-compaction' import { HOST_TEST_NOW, HOST_TEST_SESSION, @@ -43,7 +47,7 @@ function commandParams(command: AgentSessionConversationCommand) { beforeEach(async () => { resetHostTestOperationIds() acquisitions = 0 - compact.mockReset().mockResolvedValue({}) + compact.mockReset().mockResolvedValue({ outcome: 'compacted' }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) store = await AgentSessionRecordStore.open({ directory: join(directory, 'store'), @@ -117,7 +121,7 @@ describe('host conversation commands', () => { }) expect(compact).toHaveBeenCalledTimes(1) expect(adapter.dispatch).not.toHaveBeenCalled() - const history = host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) expect(history.page.submissions).toEqual([]) expect( history.page.items.some( @@ -127,12 +131,47 @@ describe('host conversation commands', () => { }) it('reports provider compaction failure without a stuck lifecycle', async () => { - compact.mockResolvedValue({ error: 'Not enough messages to compact.' }) + 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: 'Not enough messages to compact.' } + 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 () => { @@ -204,7 +243,7 @@ describe('host conversation commands', () => { }) expect(store.getRecord(HOST_TEST_SESSION)).not.toBeNull() expect(store.listVisibleSessionIds()).toEqual([nextId]) - expect(host.history({ sessionId: nextId, direction: 'tail' }).page.items).toEqual([]) + expect((await host.history({ sessionId: nextId, direction: 'tail' })).page.items).toEqual([]) expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, replayed: true, @@ -236,7 +275,13 @@ describe('host conversation commands', () => { }) expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ ok: true, - value: { state: 'completed', replacementSessionId: undefined, error: expect.any(String) } + value: { + state: 'completed', + replacementSessionId: undefined, + // The refusal's message is Orca's log text; the result words its situation. + error: "Codex couldn't start. Start a new chat to continue.", + failure: { kind: 'startFailed', refusal: { code: 'structured_agent_session_unsupported' } } + } }) expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) expect(acquisitions).toBe(1) @@ -245,6 +290,31 @@ describe('host conversation commands', () => { }) }) + it('tells the user to run /clear again when the replacement could not start', async () => { + vi.mocked(adapter.acquire).mockRejectedValueOnce(new Error('spawn codex ENOENT')) + expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ + ok: true, + value: { state: 'completed', error: "Codex couldn't start. Run /clear again." } + }) + }) + + it('keeps the situation a refused replacement start named', async () => { + vi.mocked(adapter.acquire).mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('Codex is not signed in.', 'notSignedIn') + ) + expect(await host.conversationCommand(caller, commandParams('clear'))).toMatchObject({ + ok: true, + value: { + state: 'completed', + replacementSessionId: undefined, + // The next step is the command the user ran, not a message into the old conversation. + error: 'Codex is not signed in for the selected account. Sign in, then run /clear again.', + failure: { kind: 'notSignedIn' } + } + }) + expect(store.listVisibleSessionIds()).toEqual([HOST_TEST_SESSION]) + }) + it('runs a command whose fence the client has not caught up to', async () => { const params = commandParams('compact') params.envelope.expectedRuntimeFence++ @@ -252,7 +322,7 @@ describe('host conversation commands', () => { expect(compact).toHaveBeenCalledTimes(1) }) it('allows cancellation while compaction is awaiting completion and refuses a second client', async () => { - let finish!: (value: {}) => void + let finish!: (value: StructuredSessionCompactionResult) => void compact.mockImplementation( () => new Promise((resolve) => { @@ -280,7 +350,7 @@ describe('host conversation commands', () => { }) expect(cancel).toMatchObject({ ok: true, value: { cancelled: true } }) expect(adapter.cancelTurn).toHaveBeenCalled() - finish({}) + finish({ outcome: 'compacted' }) await running }) @@ -306,7 +376,7 @@ describe('host conversation commands', () => { 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?.({}) + await compact.mock.calls[0]![0].onLateResult?.({ outcome: 'compacted' }) expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true, replayed: true, @@ -343,7 +413,7 @@ describe('host conversation commands', () => { replayed: true, value: { state: 'unknown' } }) - compact.mockResolvedValue({}) + compact.mockResolvedValue({ outcome: 'compacted' }) expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ ok: true, value: { state: 'completed' } 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 5894defc483..2075c24d7bb 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 @@ -16,9 +16,55 @@ import { } from './structured-agent-session-attach' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { + openWithAgent, + structuredAgentSessionFailureWordsContext +} 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 { + agentSessionFailureFact, + 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.' } +} + +/** A command's `error` is the sentence its row shows. */ +function conversationCommandFailure( + failure: AgentSessionFailureFact | undefined, + context: AgentSessionFailureWordsContext = {} +) { + if (!failure) { + return {} + } + const words = agentSessionFailureWords(failure, { ...context, surface: 'row' }) + return { error: words.text, failure: words.failure } +} export type ConversationCommandParams = { envelope: AgentSessionMutationEnvelope @@ -52,6 +98,8 @@ export function runStructuredConversationCommand( adapter: context.deps.adapter, callerKey: caller.callerKey, envelope, + // Only the provider can do this, so an agent at rest is started first. + prepareSession: openWithAgent(context, params.envelope), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), flushStreamedEvents: context.flushStreamedEvents, @@ -90,10 +138,7 @@ export function runStructuredConversationCommand( ? null : conversationCommandBlocked(ctx, record) if (blocked) { - return { - ok: false, - refusal: { code: 'agent_session_operation_invalid', message: blocked } - } + return { ok: false, refusal: blocked } } const replacementSessionId = command === 'clear' @@ -113,7 +158,7 @@ export function runStructuredConversationCommand( ...(replacementSessionId ? { replacementSessionId } : {}) } await store.setConversationCommand(sessionId, ctx.fence, prepared) - let error: string | undefined + let failure: AgentSessionFailureFact | undefined if (command === 'clear' && replacementSessionId) { const attach: AgentSessionAttachParams = { envelope: { @@ -149,12 +194,19 @@ export function runStructuredConversationCommand( ) { throw new Error(acquired.refusal.message) } + // The refusal's message is Orca's log text; the result keeps its situation instead. const failed = { ...prepared, replacementSessionId: undefined, phase: 'committed' as const, state: 'completed' as const, - error: acquired.refusal.message.slice(0, 4096) + ...conversationCommandFailure( + structuredAgentSessionStartFailureFact({ + refusal: acquired.refusal, + newSession: true + }), + { ...structuredAgentSessionFailureWordsContext(record), command: 'clear' } + ) } await store.setConversationCommand(sessionId, ctx.fence, failed) return { ok: true, value: failed } @@ -177,7 +229,7 @@ export function runStructuredConversationCommand( { fence: ctx.fence } ) try { - error = ( + failure = compactionFailure( await ctx.adapter.compact({ turnId: `compact:${clientOperationId}`, sessionId, @@ -191,16 +243,15 @@ export function runStructuredConversationCommand( return } await host.flushStreamedEvents(sessionId) - await ctx.journal.appendItem( - identity, - { kind: 'status', text: result.error ?? 'Conversation compacted.' }, - { fence: ctx.fence } - ) + 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', - ...(result.error ? { error: result.error.slice(0, 4096) } : {}) + ...conversationCommandFailure(late) }) await store.recordOperationOutcome({ callerKey: caller.callerKey, @@ -213,27 +264,25 @@ export function runStructuredConversationCommand( }) }) }) - ).error + ) await host.flushStreamedEvents(sessionId) } catch (cause) { await ctx.journal.appendItem( identity, - { kind: 'status', text: 'Compaction completion is unconfirmed.' }, + compactionStatusBody(agentSessionFailureFact('compactionUnconfirmed')), { fence: ctx.fence } ) throw cause } - await ctx.journal.appendItem( - identity, - { kind: 'status', text: error ?? 'Conversation compacted.' }, - { fence: ctx.fence } - ) + await ctx.journal.appendItem(identity, compactionStatusBody(failure), { + fence: ctx.fence + }) } const completed = { ...prepared, phase: 'committed' as const, state: 'completed' as const, - ...(error ? { error: error.slice(0, 4096) } : {}) + ...conversationCommandFailure(failure) } await store.setConversationCommand(sessionId, ctx.fence, completed) return { ok: true, value: completed } diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts index 6fa4d32293b..9de1925791f 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts @@ -2,6 +2,13 @@ import { describe, expect, it } from 'vitest' import { AgentSessionRewindRecordSchema } from '../../../shared/agent-session-rewind' import { restoreRewindJournalBody } from './structured-rewind-journal-body' +/** A row this build cannot place: still a row, never its stored JSON, and not a failure — a + * hostFault's "Try again" would be false for a placeholder. */ +const UNPLACEABLE = { + kind: 'status', + text: 'Orca could not show this item after the rewind.' +} + describe('rewind recovery of newer durable records', () => { it('keeps an unknown message role and block readable without discarding the row', () => { expect( @@ -43,16 +50,13 @@ describe('rewind recovery of newer durable records', () => { input: { path: 'file' }, state: 'paused-by-provider' } - expect(restoreRewindJournalBody(body)).toEqual({ kind: 'status', text: JSON.stringify(body) }) + expect(restoreRewindJournalBody(body)).toEqual(UNPLACEABLE) const status = { kind: 'status' as const, text: 'state', turnLifecycle: { turnId: 'turn', state: 'future-state' } } - expect(restoreRewindJournalBody(status)).toEqual({ - kind: 'status', - text: JSON.stringify(status) - }) + expect(restoreRewindJournalBody(status)).toEqual(UNPLACEABLE) }) it.each(['interrupted', 'unverifiable'] as const)( 'keeps a %s turn and its recorded endpoints', @@ -82,10 +86,7 @@ describe('rewind recovery of newer durable records', () => { } expect(restoreRewindJournalBody(turn)).toEqual(turn) const unknown = { ...turn, state: 'future-state' } - expect(restoreRewindJournalBody(unknown)).toEqual({ - kind: 'status', - text: JSON.stringify(unknown) - }) + expect(restoreRewindJournalBody(unknown)).toEqual(UNPLACEABLE) }) it('keeps a known turn outcome across rewind recovery in both journal shapes', () => { const turn = { diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts index addc234e2a1..99489b18769 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -9,10 +9,15 @@ import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types' type StoredBody = AgentSessionRewindRecord['retained'][number]['body'] -/** Unknown future values remain visible evidence, never invented turn or prompt state. */ +/** A row this build cannot place stays visible as a row, never invented turn or prompt state — + * and never as its stored JSON, which is Orca's record, not something a person reads. */ export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody { let normalized: unknown = body - const fallback = () => ({ kind: 'status', text: JSON.stringify(body) }) + // A placeholder, not a failure anyone can act on, so it carries no fact. + const fallback = () => ({ + kind: 'status', + text: 'Orca could not show this item after the rewind.' + }) if (body.kind === 'message') { normalized = { ...body, diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts index 5ea205a527c..af309b13f40 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts @@ -2,23 +2,28 @@ import { AGENT_SESSION_REWIND_REASONS, type AgentSessionRewindReason } from '../../../shared/agent-session-rewind' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' export function rewindRefusal(reason: AgentSessionRewindReason): { ok: false refusal: AgentSessionWireRefusal } { - const knownReason = + const rewindReason = AGENT_SESSION_REWIND_REASONS.find((value) => value === reason) ?? 'outcome-unknown' + const message = `agent_session_rewind:${rewindReason}` return { ok: false, - refusal: { - code: - knownReason === 'outcome-unknown' - ? 'agent_session_operation_unknown' - : 'agent_session_operation_invalid', - message: `agent_session_rewind:${knownReason}`, - rewindReason: knownReason - } + refusal: + rewindReason === 'outcome-unknown' + ? refuse( + 'agent_session_operation_unknown', + { reason: 'rewindUnconfirmed', rewindReason }, + message + ) + : refuse( + 'agent_session_operation_invalid', + { reason: 'rewindRefused', rewindReason }, + message + ) } } 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 index 13a22b655b3..8936a4a28d0 100644 --- 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 @@ -6,7 +6,7 @@ describe('structured compaction lifecycle', () => { const tracker = new StructuredSessionCompaction() const finished = vi.fn() const result = tracker - .run('session', 'thread', async () => ({})) + .run('session', 'thread', async () => undefined) .then((value) => { finished() return value @@ -28,7 +28,7 @@ describe('structured compaction lifecycle', () => { threadId: 'thread', turn: { id: 'compact-turn', status: 'completed' } }) - await expect(result).resolves.toEqual({}) + await expect(result).resolves.toEqual({ outcome: 'compacted' }) }) it('observes notifications arriving before the request acknowledgment', async () => { @@ -41,7 +41,10 @@ describe('structured compaction lifecycle', () => { turn: { id: 'c', status: 'failed', error: { message: 'Unavailable' } } }) }) - ).resolves.toEqual({ error: 'Unavailable' }) + ).resolves.toEqual({ + outcome: 'failed', + detail: { text: 'Unavailable', audience: 'person' } + }) }) it.each(['success', 'failed'])( @@ -63,7 +66,12 @@ describe('structured compaction lifecycle', () => { result: '' }) await expect(result).resolves.toEqual( - state === 'success' ? {} : { error: 'Not enough messages to compact.' } + state === 'success' + ? { outcome: 'compacted' } + : { + outcome: 'failed', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + } ) } ) @@ -72,26 +80,26 @@ describe('structured compaction lifecycle', () => { const tracker = new StructuredSessionCompaction() const pending = tracker.run('s', 'p', async () => {}) tracker.ended('s') - await expect(pending).resolves.toEqual({ error: 'The provider exited during compaction.' }) + 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({}) + 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 () => ({})) + 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({}) + expect(late).toHaveBeenCalledWith({ outcome: 'compacted' }) expect(invoke).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -100,9 +108,34 @@ describe('structured compaction lifecycle', () => { it('does not mistake an unrelated completed turn for compaction', async () => { const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => ({})) + 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({ error: 'Compaction did not complete.' }) + 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 index 328c283dc11..5f5c88d0b07 100644 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts @@ -1,10 +1,32 @@ +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 - error?: string + /** The provider reported the compaction failed, with its words when it gave any. */ + failed?: { detail?: ProviderDiagnostic } compacted: boolean - finish: (result: { error?: string }) => void + finish: (result: StructuredSessionCompactionResult) => void } function record(value: unknown): Record { @@ -26,17 +48,17 @@ export class StructuredSessionCompaction { async run( sessionId: string, identity: string, - invoke: () => Promise, - onLateResult?: (result: { error?: string }) => Promise, + invoke: () => Promise, + onLateResult?: (result: StructuredSessionCompactionResult) => Promise, commandTurnId?: string - ): Promise<{ error?: string }> { + ): Promise { if (this.pending.has(sessionId)) { throw new Error('Compaction is already running.') } let timer: ReturnType let expired = false - const completion = new Promise<{ error?: string }>((resolve, reject) => { - const finish = (result: { error?: string }) => { + const completion = new Promise((resolve, reject) => { + const finish = (result: StructuredSessionCompactionResult) => { this.pending.delete(sessionId) if (expired && onLateResult) { void onLateResult(result).catch((error) => @@ -60,9 +82,9 @@ export class StructuredSessionCompaction { // Observe rejection even while invoke is waiting for its own receipt. void completion.catch(() => {}) try { - const admission = record(await invoke()) - if (typeof admission.error === 'string') { - this.pending.get(sessionId)?.finish({ error: admission.error }) + const refusal = await invoke() + if (refusal) { + this.pending.get(sessionId)?.finish(compactionFailed(refusal.detail)) } return await completion } catch (error) { @@ -89,7 +111,8 @@ export class StructuredSessionCompaction { } ended(sessionId: string): void { - this.pending.get(sessionId)?.finish({ error: 'The provider exited during compaction.' }) + // It may have compacted before it exited. + this.pending.get(sessionId)?.finish({ outcome: 'unconfirmed' }) } codex(sessionId: string, method: string, value: unknown): void { @@ -108,9 +131,11 @@ export class StructuredSessionCompaction { if (method === 'turn/completed' && turn.id === pending.turnId) { const error = record(turn.error).message pending.finish( - turn.status === 'completed' && pending.compacted - ? {} - : { error: typeof error === 'string' ? error : 'Compaction did not complete.' } + turn.status !== 'completed' + ? compactionFailed( + typeof error === 'string' ? providerDiagnostic(error, 'person') : undefined + ) + : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } ) } } @@ -121,8 +146,12 @@ export class StructuredSessionCompaction { return } if (message.compact_result === 'failed') { - pending.error = - typeof message.compact_error === 'string' ? message.compact_error : 'Compaction 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 @@ -132,12 +161,13 @@ export class StructuredSessionCompaction { message.is_error === true || (typeof message.subtype === 'string' && message.subtype.startsWith('error')) ) { - pending.error ??= 'Compaction did not complete.' + pending.failed ??= {} } - const error = - pending.error ?? - (pending.compacted ? undefined : 'Compaction was not confirmed by the provider.') - pending.finish(error ? { error } : {}) + pending.finish( + pending.failed + ? compactionFailed(pending.failed.detail) + : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } + ) } } } diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index cbb0857c859..915bbad5e54 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -1,4 +1,4 @@ -import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { AgentJournalPlainStatusItem } from '../../../shared/agent-session-journal-types' import { boundInlineText, boundPayload, @@ -12,7 +12,7 @@ import { } from './provider-frame-disposition' export type UnhandledProviderFrameJournalItem = { - body: AgentJournalStatusItem + body: AgentJournalPlainStatusItem /** Why the frame surfaced. Error frames are exempt from generic-row caps. */ classification: 'timeline-substantive' | 'error-surface' } diff --git a/src/main/native-chat/claude-structured-managed-account-support.ts b/src/main/native-chat/claude-structured-managed-account-support.ts index dccf6216bda..3b42c44c443 100644 --- a/src/main/native-chat/claude-structured-managed-account-support.ts +++ b/src/main/native-chat/claude-structured-managed-account-support.ts @@ -43,12 +43,19 @@ export function structuredClaudeMatchesActiveManagedAccount( // apart after the fact: honest deselection, where ambient auth is the truth and the UI names no // identity, and the WSL-only case, where the prune emptied the host slot and persisted null // while the UI still names the WSL account. The presence of any WSL-bound account decides. - return !accounts.some((candidate) => candidate.managedAuthRuntime === 'wsl') + return !hasWslBoundClaudeAccount(settings) } const active = accounts.find((candidate) => candidate.id === activeHostId) return active ? active.managedAuthRuntime !== 'wsl' : false } +/** Whether any managed Claude account lives in WSL, the account shape the gate refuses over. */ +export function hasWslBoundClaudeAccount(settings: ClaudeManagedAccountGateSettings): boolean { + return (settings.claudeManagedAccounts ?? []).some( + (candidate) => candidate.managedAuthRuntime === 'wsl' + ) +} + /** Reads the gate's settings, answering null when they cannot be read so callers refuse. */ export function readClaudeManagedAccountGateSettings( getSettings: () => ClaudeManagedAccountGateSettings diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index d470735b032..e64e8f799d8 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -5,6 +5,7 @@ // journal, and a call site written there would compile however wrong it was. The runtime hands over // the facts it owns — the account homes it recognises, the records it holds — and this decides. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { AgentSessionProviderHandle } from '../../shared/agent-session-journal-types' import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' @@ -106,10 +107,11 @@ export function findConflictingStructuredAdoption(input: { export function structuredAdoptionConflictError( ownership: StructuredAgentSessionAdoptionOwnership ): Error { - return new Error( + return agentSessionRefusalError( agentSessionLeaseAdmitsWriter(ownership.lease) ? 'agent_session_conflict' - : 'agent_session_ownership_unknown' + : 'agent_session_ownership_unknown', + { reason: 'conversationHeldElsewhere' } ) } @@ -152,5 +154,7 @@ export async function resolveStructuredAgentSessionAdoption(input: { } // Refuse rather than fall back to the default home. Resuming under a home that does not hold the // conversation is how a "resume" silently becomes a blank chat wearing the old chat's name. - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) } diff --git a/src/main/persistence-ssh-pending-pty-kill.test.ts b/src/main/persistence-ssh-pending-pty-kill.test.ts index 1d9c665c56f..19298b3288f 100644 --- a/src/main/persistence-ssh-pending-pty-kill.test.ts +++ b/src/main/persistence-ssh-pending-pty-kill.test.ts @@ -50,6 +50,19 @@ describe('Store SSH pending PTY kills', () => { ]) }) + it('keeps an epoch-scoped intent with no incarnation across a restart, and drops a legacy one', async () => { + const store = await createStore() + for (const ptyId of ['pty2:epoch-a:1', 'pty-2']) { + store.recordSshRemotePtyKillIntent('ssh-1', ptyId, { requestedAt: NOW, attempts: 0 }) + } + store.flush() + + const reloaded = await createStore() + expect(reloaded.getSshRemotePtyKillIntents('ssh-1', NOW)).toEqual([ + { ptyId: 'pty2:epoch-a:1', intent: { requestedAt: NOW, attempts: 0 } } + ]) + }) + // A kill issued while the provider was already unregistered writes no lease of its own, and that // offline close is the case most likely to strand a remote shell. it('records an intent for a PTY that has no lease row yet', async () => { diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts index 58b0cd0473a..a23bd421dd5 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-normalization.ts @@ -61,7 +61,7 @@ export function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | return null } const now = Date.now() - const pendingKill = normalizeSshPendingPtyKill(raw.pendingKill) + const pendingKill = normalizeSshPendingPtyKill(raw.pendingKill, raw.ptyId) return { targetId: raw.targetId, ptyId: raw.ptyId, diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-kill-intent-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-kill-intent-operations.ts index 52d1c51e9f2..a499ba10253 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-kill-intent-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-kill-intent-operations.ts @@ -126,7 +126,7 @@ export function recordSshRemotePtyKillIntent( const prior = existing.pendingKill // Same incarnation means a repeated close; a recycled relay id starts a new intent lifetime. existing.pendingKill = - prior?.incarnationId === intent.incarnationId + prior && prior.incarnationId === intent.incarnationId ? { ...intent, requestedAt: Math.min(prior.requestedAt, now), diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index f902ff7d252..c90544f7502 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -92,7 +92,10 @@ export class PtyBindingPersistenceOperations { const paneKey = `${args.tabId}:${args.leafId}` const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId const session = sessions.getWorkspaceSession(resolvedHostId) - if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey)) { + const partitions = sessions + .getWorkspaceSessionHostIds() + .map((hostId) => sessions.getWorkspaceSession(hostId)) + if (ptyBindingIsRefused(args, session, bindingWorktreeId, paneKey, partitions)) { outcome = 'refused' return { value: false, persist: false } } diff --git a/src/main/persistence/loading-store/pty-binding-refusals.ts b/src/main/persistence/loading-store/pty-binding-refusals.ts index 3c61056bfca..636e69bd936 100644 --- a/src/main/persistence/loading-store/pty-binding-refusals.ts +++ b/src/main/persistence/loading-store/pty-binding-refusals.ts @@ -1,3 +1,4 @@ +import { hasClosedTerminalTabRecord } from '../../../shared/closed-terminal-tab-tombstones' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' @@ -13,7 +14,7 @@ export type PtyBindingRefusalRequest = { } /** - * The four fences a binding must clear before anything is mutated, so a refusal leaves nothing + * The five fences a binding must clear before anything is mutated, so a refusal leaves nothing * half-written. Order matters: every `false` here is returned before the write path or the * fast lane can run, which is what the relay's lease expiry and the stable-owner throw rely on. */ @@ -21,7 +22,10 @@ export function ptyBindingIsRefused( args: PtyBindingRefusalRequest, session: WorkspaceSessionState, bindingWorktreeId: string, - paneKey: string + paneKey: string, + /** Every host partition: a close is recorded where the tab lived, which need not be where this + * binding lands (a relay reattach binds into `local`). */ + partitions: readonly WorkspaceSessionState[] = [session] ): boolean { if (args.expectedSourceBinding) { const expected = args.expectedSourceBinding @@ -56,6 +60,19 @@ export function ptyBindingIsRefused( return true } } + const existingTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId + ) + // Why: a closed tab's spawn can commit after the close, even after a crash and relaunch; tab + // ids are uuids, so a recorded id is never a new tab. + if ( + !existingTab && + partitions.some((partition) => + hasClosedTerminalTabRecord(partition.closedTerminalTabTombstonesByTabId, args.tabId) + ) + ) { + return true + } // Mirrors the four creating branches of the write path — mint a tab, mint a root leaf, split // the root and graft a leaf, mint a layout — each of which sets `terminalMembershipChanged`. if ( @@ -65,9 +82,6 @@ export function ptyBindingIsRefused( return true } if (args.mayCreate === false) { - const existingTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === args.tabId - ) const existingLayout = session.terminalLayoutsByTabId?.[args.tabId] const wouldCreateTopology = !existingTab || diff --git a/src/main/persistence/loading-store/pty-binding-span.ts b/src/main/persistence/loading-store/pty-binding-span.ts index 58bed424a51..903d93c4967 100644 --- a/src/main/persistence/loading-store/pty-binding-span.ts +++ b/src/main/persistence/loading-store/pty-binding-span.ts @@ -10,11 +10,13 @@ export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw */ export type PtyBindingOrigin = 'reattach' | 'spawn' | 'relay_reattach' | 'split' | 'unknown' +export type PtySpawnCommitOrigin = Extract + /** The spawn-commit paths share one rule: a split outranks a reattach, a reattach outranks a spawn. */ export function spawnCommitBindingOrigin( commit: { isReattach?: boolean; agentSessionEnsure?: { disposition: string } }, expectedSourceBinding?: unknown -): PtyBindingOrigin { +): PtySpawnCommitOrigin { if (expectedSourceBinding !== undefined) { return 'split' } diff --git a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts index b1d185c38b1..2777fdfde4b 100644 --- a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts +++ b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts @@ -37,7 +37,6 @@ export function createPtySpawnCommitDependencies( sendPtyExitToRenderer: unexpectedPreflight, finishPtyShutdown: unexpectedPreflight, retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map(), get mainWindow() { return unexpectedPreflight() } diff --git a/src/main/persistence/loading-store/terminal-close-async-durability.test.ts b/src/main/persistence/loading-store/terminal-close-async-durability.test.ts index 4e74eea38ae..090d2340932 100644 --- a/src/main/persistence/loading-store/terminal-close-async-durability.test.ts +++ b/src/main/persistence/loading-store/terminal-close-async-durability.test.ts @@ -98,9 +98,11 @@ it.each([ const before = persistedLeafIds() const gate = authority.pause() let acknowledged = false - const closing = runtime.closeTerminalSurfaceFromRenderer(binding.worktreeId, target).then(() => { - acknowledged = true - }) + const closing = runtime + .closeTerminalSurfaceFromRenderer({ worktreeId: binding.worktreeId, target: target }) + .then(() => { + acknowledged = true + }) try { await Promise.race([gate.started.promise, closing]) expect(acknowledged).toBe(false) @@ -140,9 +142,12 @@ it('keeps a renderer close when its durable write fails', async () => { const { authority, runtime, persistedLeafIds, liveLeafIds } = await closeFixture() vi.spyOn(console, 'error').mockImplementation(() => {}) const gate = authority.pause() - const closing = runtime.closeTerminalSurfaceFromRenderer(binding.worktreeId, { - kind: 'tab', - tabId: binding.tabId + const closing = runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: binding.worktreeId, + target: { + kind: 'tab', + tabId: binding.tabId + } }) await Promise.race([gate.started.promise, closing]) gate.finish.reject(new Error('close disk refused')) @@ -165,10 +170,13 @@ it('commits nothing for a pane that restarted while its close waited for the wri ptyId: 'restarted-pty', incarnationId: 'ffffffff-ffff-4fff-8fff-ffffffffffff' }) - const closing = runtime.closeTerminalSurfaceFromRenderer(binding.worktreeId, { - kind: 'pane', - tabId: binding.tabId, - leafId: binding.leafId + const closing = runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: binding.worktreeId, + target: { + kind: 'pane', + tabId: binding.tabId, + leafId: binding.leafId + } }) gate.finish.resolve() await earlierWrite @@ -193,9 +201,12 @@ it('commits a renderer tab close whose split pane bound while the close waited f incarnationId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', expectedSourceBinding: binding }) - const closing = runtime.closeTerminalSurfaceFromRenderer(binding.worktreeId, { - kind: 'tab', - tabId: binding.tabId + const closing = runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: binding.worktreeId, + target: { + kind: 'tab', + tabId: binding.tabId + } }) gate.finish.resolve() await earlierWrite @@ -209,4 +220,8 @@ it('commits a renderer tab close whose split pane bound while the close waited f .getWorkspaceSession() .tabsByWorktree[binding.worktreeId]?.some((tab) => tab.id === binding.tabId) ).toBe(false) + // Why: skipping the owner fence for the layout owner's own close must not skip its record. + expect(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[binding.tabId]).toEqual( + expect.objectContaining({ worktreeId: binding.worktreeId, reason: 'user' }) + ) }) diff --git a/src/main/persistence/runtime-authored-workspace-session-fields.ts b/src/main/persistence/runtime-authored-workspace-session-fields.ts index e87b47cd535..80a9d30b532 100644 --- a/src/main/persistence/runtime-authored-workspace-session-fields.ts +++ b/src/main/persistence/runtime-authored-workspace-session-fields.ts @@ -4,9 +4,9 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state * Keeps session fields the renderer persist snapshot does not author across a full write. * * A session write replaces the stored object. Zustand-built payloads omit runtime-owned - * client-hosted pages, and they omit-when-empty the write-once default-terminal-tab marker. - * Without this, those slices vanish on the next desktop write and only show up missing after - * restart. + * client-hosted pages and terminal close records, and they omit-when-empty the write-once + * default-terminal-tab marker. Without this, those slices vanish on the next desktop write and + * only show up missing after restart. * * Callers do not opt in: the Store applies this inside setLocalWorkspaceSession and * setHostWorkspaceSession, so the before-unload stage path inherits it too. @@ -44,6 +44,16 @@ export function preserveRuntimeAuthoredWorkspaceSessionFields( clientHostedBrowserPagesByWorktree: prior.clientHostedBrowserPagesByWorktree } } + // Why: close records are main's alone (its close transaction); a renderer save never carries them. + if ( + next.closedTerminalTabTombstonesByTabId === undefined && + prior?.closedTerminalTabTombstonesByTabId !== undefined + ) { + result = { + ...result, + closedTerminalTabTombstonesByTabId: prior.closedTerminalTabTombstonesByTabId + } + } // Why union: persist snapshots omit this write-once map (empty Zustand slice, omit-when-empty // payload). Treating omission as "never applied" re-spawns default terminals on every attach. return unionWriteOnceDefaultTerminalTabsApplied(result, prior) diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts index ee6e7a5b9d0..7f196c9eb81 100644 --- a/src/main/plugins/plugin-host-methods.test.ts +++ b/src/main/plugins/plugin-host-methods.test.ts @@ -194,10 +194,14 @@ describe('terminal.sendText explicit worktree routing', () => { { includeVisualLayouts: false } ) expect(delegate.sendTerminal).toHaveBeenCalledTimes(1) - expect(delegate.sendTerminal).toHaveBeenCalledWith(terminalId, { - text: 'echo hi', - enter: true - }) + expect(delegate.sendTerminal).toHaveBeenCalledWith( + terminalId, + { + text: 'echo hi', + enter: true + }, + { inputKind: 'driving' } + ) expect(vi.mocked(delegate.listTerminals).mock.invocationCallOrder[0]!).toBeLessThan( vi.mocked(delegate.sendTerminal).mock.invocationCallOrder[0]! ) diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts index 266b819dbae..731934d5595 100644 --- a/src/main/plugins/plugin-host-service-bindings.ts +++ b/src/main/plugins/plugin-host-service-bindings.ts @@ -3,6 +3,7 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-hos import type { PluginHostServices } from './plugin-host-methods' import { PluginSecretsStore } from './plugin-secrets-store' import { PluginKvStore } from './plugin-storage-store' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' /** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */ export type PluginRuntimeDelegate = { @@ -19,7 +20,8 @@ export type PluginRuntimeDelegate = { ): Promise<{ terminals: { handle: string; title: string | null }[] }> sendTerminal( handle: string, - action: { text?: string; enter?: boolean } + action: { text?: string; enter?: boolean }, + options: { inputKind: TerminalInputKind } ): Promise<{ accepted: boolean }> dispatchPluginNotification(input: { pluginId: string @@ -59,7 +61,7 @@ export function bindPluginHostServices(input: { .map((terminal) => ({ id: terminal.handle })) }, sendTerminalText: async (terminalId, action) => { - const result = await delegate.sendTerminal(terminalId, action) + const result = await delegate.sendTerminal(terminalId, action, { inputKind: 'driving' }) return { accepted: result.accepted } }, dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification), diff --git a/src/main/preflight/freebuff-remote-detection.test.ts b/src/main/preflight/freebuff-remote-detection.test.ts new file mode 100644 index 00000000000..de8408da088 --- /dev/null +++ b/src/main/preflight/freebuff-remote-detection.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { getActiveMultiplexerMock } = vi.hoisted(() => ({ + getActiveMultiplexerMock: vi.fn() +})) + +vi.mock('../ssh/ssh-target-registry', () => ({ + getActiveMultiplexer: getActiveMultiplexerMock +})) + +import { detectRemoteAgents } from './agent-detection' + +afterEach(() => vi.resetAllMocks()) + +describe('Freebuff SSH detection', () => { + it('asks the execution host to detect Freebuff separately from Codebuff', async () => { + const request = vi.fn().mockResolvedValue({ agents: ['freebuff'] }) + getActiveMultiplexerMock.mockReturnValue({ isDisposed: () => false, request }) + + await expect(detectRemoteAgents({ connectionId: 'ssh-freebuff' })).resolves.toEqual([ + 'freebuff' + ]) + expect(getActiveMultiplexerMock).toHaveBeenCalledWith('ssh-freebuff') + expect(request).toHaveBeenCalledWith('preflight.detectAgents', { + commands: expect.arrayContaining([ + { id: 'freebuff', cmd: 'freebuff' }, + { id: 'codebuff', cmd: 'codebuff' } + ]) + }) + }) +}) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index a8e2f9ce609..728e8a1273a 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -118,6 +118,8 @@ export class LocalPtyProvider implements IPtyProvider { clearBuffer(id: string): Promise { return clearLocalPtyBuffer(id) } + // A direct PTY keeps no terminal model of its own. + async resetInputModes(_id: string): Promise {} closeStartupQueryAuthority(id: string): number { return closeLocalPtyStartupQueryAuthority(id) } diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 47aaac6cef0..645fcacb2d0 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -8,6 +8,7 @@ import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' import { stripPiProcessOwnerEnv } from '../pty/pi-process-owner-env' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { isWindowsGitBashShellPath } from '../git-bash' +import { applyScrubSafeAgentEnvAliases } from '../../shared/agent-hook-scrub-safe-env' import { removeUnspecifiedPaneIdentityEnv } from './local-pty-launch-helpers' import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' import type { LocalPtyProviderOptions } from './local-pty-provider-types' @@ -42,6 +43,8 @@ export function buildLocalPtySpawnEnvironment(args: { if (spawn.env?.TERM) { spawnEnv.TERM = spawn.env.TERM } + // Why after the strips and deletes: an alias must never outlive the value it mirrors. + applyScrubSafeAgentEnvAliases(spawnEnv) spawnEnv.LANG ??= 'en_US.UTF-8' diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 735393449f2..8009260923d 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -2,6 +2,7 @@ import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' import { usesNodePtySpawnHelper } from '../../shared/node-pty-spawn-helper' +import { TERMINAL_SPAWN_ISSUE_REQUEST } from '../../shared/terminal-spawn-error-copy' import { hostReportsChildExitStatus, wrapShellSpawnForMacosTccAttribution @@ -307,7 +308,6 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu const diag = formatLocalPtyEnvironmentDiag({ shell: shellPath, cwd }) throw new Error( - `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ` + - `If this persists, please file an issue.` + `Failed to spawn shell "${shellPath}": ${primaryError ?? 'unknown error'} (${diag}). ${TERMINAL_SPAWN_ISSUE_REQUEST}` ) } diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index cc63cfb9d09..c6f00c98354 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -109,6 +109,7 @@ describe('PTY provider dispatch', () => { getCwd: vi.fn(), getInitialCwd: vi.fn(), clearBuffer: vi.fn(), + resetInputModes: vi.fn(), acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(), getForegroundProcess: vi.fn(), diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 4b31fbbb2c8..d2b38bd4b2f 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -224,6 +224,8 @@ export type IPtyProvider = { getCwd(id: string): Promise getInitialCwd(id: string): Promise 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. */ closeStartupQueryAuthority?: (id: string) => Promise | number acknowledgeDataEvent(id: string, charCount: number): void diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 90b41d9656a..2886a00e8b6 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -52,8 +52,8 @@ export type PtySpawnResult = { } /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags - * (terminal-query-authority.md §kitty). Never replayed into a renderer - * xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */ + * (terminal-query-authority.md §kitty). Renderers re-assert them in their + * replay epilogue, after the payload's screen switches. */ snapshotKittyKeyboardFlags?: number /** Renderer-domain sequence main reconciled for the attach boundary those * flags describe. Set by main, not the provider. */ diff --git a/src/main/providers/ssh-pty-provider-rpc-operations.ts b/src/main/providers/ssh-pty-provider-rpc-operations.ts index bcd847de27b..361bdc15715 100644 --- a/src/main/providers/ssh-pty-provider-rpc-operations.ts +++ b/src/main/providers/ssh-pty-provider-rpc-operations.ts @@ -34,6 +34,9 @@ export function createSshPtyProviderRpcOperations({ mux, toRelayPtyId }: SshPtyP clearBuffer: async (id: string): Promise => { await mux.request('pty.clearBuffer', { id: toRelayPtyId(id) }) }, + resetInputModes: async (id: string): Promise => { + await mux.request('pty.resetInputModes', { id: toRelayPtyId(id) }) + }, closeStartupQueryAuthority: async (id: string): Promise => { const result = (await mux.request('pty.closeStartupQueryAuthority', { id: toRelayPtyId(id) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 76b5d9f85e4..854e076cec5 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -55,6 +55,7 @@ export class SshPtyProvider implements IPtyProvider { getCwd = (id: string): Promise => this.rpcOperations.getCwd(id) getInitialCwd = (id: string): Promise => this.rpcOperations.getInitialCwd(id) clearBuffer = (id: string): Promise => this.rpcOperations.clearBuffer(id) + resetInputModes = (id: string): Promise => this.rpcOperations.resetInputModes(id) closeStartupQueryAuthority = (id: string): Promise => this.rpcOperations.closeStartupQueryAuthority(id) acknowledgeDataEvent = (id: string, charCount: number): void => diff --git a/src/main/qoder/hook-service.test.ts b/src/main/qoder/hook-service.test.ts new file mode 100644 index 00000000000..3af282ecd76 --- /dev/null +++ b/src/main/qoder/hook-service.test.ts @@ -0,0 +1,102 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as OsModule from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +const sandbox = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => sandbox.home +})) +vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } })) +import { getManagedLifecycleHook, hasSameManagedHookInvocation } from '../claude/hook-settings' +import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' +import { qoderHookService, QODER_HOOK_EVENTS } from './hook-service' +import { markQoderWorkspaceTrusted, withQoderTrustedWorkspace } from './workspace-trust' + +beforeAll(() => { + sandbox.home = mkdtempSync(join(tmpdir(), 'orca-qoder-test-')) + mkdirSync(join(sandbox.home, '.qoder')) +}) +afterAll(() => { + rmSync(sandbox.home, { recursive: true, force: true }) +}) + +describe('Qoder managed configuration', () => { + it('preserves user hooks and trust, installs idempotently, then removes only Orca hooks', () => { + const path = join(sandbox.home, '.qoder', 'settings.json') + const userHook = { hooks: [{ type: 'command', command: 'echo user-hook' }] } + writeFileSync( + path, + JSON.stringify({ + model: 'custom', + hooks: { Stop: [userHook] }, + permissions: { trustDirectories: ['/existing'] } + }) + ) + expect(qoderHookService.install().state).toBe('installed') + expect(qoderHookService.install().state).toBe('installed') + const installed = JSON.parse(readFileSync(path, 'utf8')) + for (const event of QODER_HOOK_EVENTS) { + expect(installed.hooks[event]).toHaveLength(event === 'Stop' ? 2 : 1) + } + expect(installed.hooks.TeammateIdle).toBeUndefined() + expect(installed.statusLine).toBeUndefined() + markQoderWorkspaceTrusted('/new-workspace') + const trusted = JSON.parse(readFileSync(path, 'utf8')) + expect(trusted.permissions.trustDirectories).toEqual(['/existing', '/new-workspace']) + expect(trusted.hooks).toEqual(installed.hooks) + expect(qoderHookService.remove().state).toBe('not_installed') + expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ + model: 'custom', + hooks: { Stop: [userHook] } + }) + }) + + it("writes every Qoder event whatever Claude version is passed, since Claude's table never applies", () => { + const path = join(sandbox.home, '.qoder', 'settings.json') + writeFileSync(path, JSON.stringify({ hooks: {} })) + expect(qoderHookService.install({ claudeVersion: '1.0.62' }).state).toBe('installed') + const installed = JSON.parse(readFileSync(path, 'utf8')) + expect(Object.keys(installed.hooks).sort()).toEqual([...QODER_HOOK_EVENTS].sort()) + expect(installed.statusLine).toBeUndefined() + expect(qoderHookService.remove().state).toBe('not_installed') + }) + + it('refuses malformed settings instead of overwriting them', () => { + const path = join(sandbox.home, '.qoder', 'settings.json') + writeFileSync(path, '{broken') + expect(qoderHookService.install().state).toBe('error') + markQoderWorkspaceTrusted('/new-workspace') + expect(readFileSync(path, 'utf8')).toBe('{broken') + expect(withQoderTrustedWorkspace({ permissions: 'invalid' }, '/workspace')).toBeNull() + expect( + withQoderTrustedWorkspace({ permissions: { trustDirectories: true } }, '/workspace') + ).toBeNull() + }) +}) + +describe('Qoder Windows hook shell', () => { + it('uses the documented explicit shell without relying on Git Bash or another PowerShell hop', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + const hook = getManagedLifecycleHook( + 'C:\\Users\\a b\\.orca\\agent-hooks\\qoder-hook.cmd', + { + configDirName: '.qoder', + scriptBaseName: 'qoder-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + { gitBashAvailable: true } + ) + expect(hook.shell).toBe('powershell') + expect(hook.command).toContain('$env:USERPROFILE') + expect(hook.command).not.toMatch(/EncodedCommand|ExecutionPolicy|\|\|/) + expect(createManagedCommandMatcher('qoder-hook.cmd')(hook.command)).toBe(true) + expect(hasSameManagedHookInvocation({ ...hook, shell: undefined }, hook)).toBe(false) + } finally { + vi.restoreAllMocks() + } + }) +}) diff --git a/src/main/qoder/hook-service.ts b/src/main/qoder/hook-service.ts new file mode 100644 index 00000000000..ac9e869638c --- /dev/null +++ b/src/main/qoder/hook-service.ts @@ -0,0 +1,38 @@ +import type { ClaudeManagedHookPlan } from '../claude/claude-managed-hook-events' +import { ClaudeHookService } from '../claude/hook-service' + +// Qoder documents Claude-shaped hooks at https://docs.qoder.com/cli/hooks. +export const QODER_HOOK_EVENTS = [ + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure', + 'Notification', + 'PostCompact' +] as const + +// Why: Qoder's own CLI reads these events, so Claude's version table never applies; the statusline +// usage feed is Claude-only. +export const QODER_MANAGED_HOOK_PLAN: ClaudeManagedHookPlan = { + install: QODER_HOOK_EVENTS.map((eventName) => ({ eventName, definition: {} })), + retire: [], + statusLine: 'leave' +} + +export const qoderHookService = new ClaudeHookService({ + agent: 'qoder', + source: 'qoder', + displayName: 'Qoder CLI', + settings: { + configDirName: '.qoder', + scriptBaseName: 'qoder-hook', + usesWindowsCompatLauncher: true, + windowsHookShell: 'powershell' + }, + hookPlan: QODER_MANAGED_HOOK_PLAN +}) diff --git a/src/main/qoder/workspace-trust.ts b/src/main/qoder/workspace-trust.ts new file mode 100644 index 00000000000..2a834f7cbcb --- /dev/null +++ b/src/main/qoder/workspace-trust.ts @@ -0,0 +1,78 @@ +import { realpathSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, posix } 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( + config: Record, + workspacePath: string +): Record | null { + if (config.permissions !== undefined && !isPlainObject(config.permissions)) { + return null + } + const permissions = isPlainObject(config.permissions) ? config.permissions : {} + if (permissions.trustDirectories !== undefined && !Array.isArray(permissions.trustDirectories)) { + return null + } + const existing = Array.isArray(permissions.trustDirectories) ? permissions.trustDirectories : [] + if (existing.includes(workspacePath)) { + return config + } + return { + ...config, + permissions: { ...permissions, trustDirectories: [...existing, workspacePath] } + } +} + +export function markQoderWorkspaceTrusted(workspacePath: string): void { + let canonicalPath = workspacePath + try { + canonicalPath = realpathSync.native(workspacePath) + } catch { + /* Keep the supplied path when absent. */ + } + const configPath = join(homedir(), '.qoder', 'settings.json') + const config = readHooksJson(configPath) + if (!config) { + return + } + const updated = withQoderTrustedWorkspace(config, canonicalPath) + if (updated && updated !== config) { + 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/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 021de01108d..f51839d108b 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -11,6 +11,7 @@ import { readGrokAuthSession } from './grok-auth' import { fetchCursorRateLimits } from './cursor-fetcher' import { readCursorAuthSession } from './cursor-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' +import { fetchZcodeRateLimits } from './zcode-usage-fetcher' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' export type Deferred = { @@ -92,6 +93,7 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor')) + vi.mocked(fetchZcodeRateLimits).mockImplementation(async () => unavailableProvider('zcode')) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -110,6 +112,7 @@ export function resetRateLimitProviderMocks(): void { status: 'unavailable' }) vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor')) + vi.mocked(fetchZcodeRateLimits).mockResolvedValue(unavailableProvider('zcode')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 2deeadfcdaa..bb5de4b6485 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -32,6 +32,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 9f2fc2c8244..3696f434dec 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -39,6 +39,8 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) + vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-cursor-usage.test.ts b/src/main/rate-limits/service-cursor-usage.test.ts index 37984b89a52..3646f9f8586 100644 --- a/src/main/rate-limits/service-cursor-usage.test.ts +++ b/src/main/rate-limits/service-cursor-usage.test.ts @@ -23,6 +23,8 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: v vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./zcode-usage-fetcher', () => ({ fetchZcodeRateLimits: vi.fn() })) + vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 300292efcc2..23f0a0e6bba 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -39,6 +39,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 3c51a09aa5f..07f6ccf0d27 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -36,6 +36,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 8b225336472..93f95de7847 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -33,6 +33,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index b184d51eda6..9577a87d6a3 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -7,6 +7,7 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' +import { fetchZcodeRateLimits } from './zcode-usage-fetcher' import { readGrokAuthSession } from './grok-auth' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { @@ -40,6 +41,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -73,6 +78,57 @@ describe('RateLimitService', () => { resetRateLimitProviderMocks() }) + it('publishes a ZCode quota snapshot alongside the other providers', async () => { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 8)) + vi.mocked(fetchZcodeRateLimits).mockResolvedValue(okProvider('zcode', 42)) + const service = new RateLimitService() + + await service.refresh() + + expect(fetchZcodeRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().zcode?.session?.usedPercent).toBe(42) + expect(service.getState().codex?.session?.usedPercent).toBe(8) + }) + + it('does not keep a previous ZCode account quota after a failed account switch', async () => { + vi.mocked(fetchZcodeRateLimits) + .mockResolvedValueOnce({ + ...okProvider('zcode', 42), + usageMetadata: { source: 'web', authProvenance: 'account-a' } + }) + .mockResolvedValueOnce({ + ...errorProvider('zcode', 'request failed'), + usageMetadata: { source: 'web', authProvenance: 'account-b', failureKind: 'network' } + }) + const service = new RateLimitService() + + await service.refresh() + await service.refresh() + + expect(service.getState().zcode?.status).toBe('error') + expect(service.getState().zcode?.session).toBeNull() + }) + + it('keeps a recent ZCode quota after a failed retry for the same account', async () => { + vi.mocked(fetchZcodeRateLimits) + .mockResolvedValueOnce({ + ...okProvider('zcode', 42), + usageMetadata: { source: 'web', authProvenance: 'account-a' } + }) + .mockResolvedValueOnce({ + ...errorProvider('zcode', 'request failed'), + usageMetadata: { source: 'web', authProvenance: 'account-a', failureKind: 'network' } + }) + const service = new RateLimitService() + + await service.refresh() + await service.refresh() + + expect(service.getState().zcode?.status).toBe('error') + expect(service.getState().zcode?.session?.usedPercent).toBe(42) + }) + it('does not reread Grok auth when callers read state snapshots', () => { vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'ok', diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 7c83bd7d026..54296c396b9 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -41,6 +41,10 @@ vi.mock('./opencode-go-usage-source-selection', () => ({ fetchOpenCodeGoUsage: vi.fn() })) +vi.mock('./zcode-usage-fetcher', () => ({ + fetchZcodeRateLimits: vi.fn() +})) + vi.mock('./minimax/minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 9e9d9506a05..70b58933c16 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -36,7 +36,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ miniMaxResult ], grokResultPromise, - cursorResultPromise + cursorResultPromise, + zcodeResultPromise } = prepared if (signal.aborted) { return @@ -193,12 +194,17 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.state.minimax }) - const [grokSettled, cursorSettled] = await Promise.all([grokResultPromise, cursorResultPromise]) + const [grokSettled, cursorSettled, zcodeSettled] = await Promise.all([ + grokResultPromise, + cursorResultPromise, + zcodeResultPromise + ]) if (signal.aborted) { return } const grok = settleSiblingProviderResult('grok', grokSettled) const cursor = settleSiblingProviderResult('cursor', cursorSettled) + const zcode = settleSiblingProviderResult('zcode', zcodeSettled) // Why: the stale policy keeps a recent snapshot through a failed refresh, but // a snapshot belonging to a different Cursor account must not survive the // switch — the Accounts pane would name the new account beside the old @@ -210,12 +216,23 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ previousCursorAccount !== undefined && cursorAccount !== undefined && previousCursorAccount !== cursorAccount + const previousZcodeAccount = previousState.zcode?.usageMetadata?.authProvenance + const zcodeAccount = zcode.usageMetadata?.authProvenance + const sameZcodeAccount = + previousZcodeAccount !== undefined && + zcodeAccount !== undefined && + previousZcodeAccount === zcodeAccount this.trackActiveFailureStreak('grok', grok) this.trackActiveFailureStreak('cursor', cursor) + this.trackActiveFailureStreak('zcode', zcode) this.updateState({ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok), - cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor) + cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor), + zcode: + zcode.status === 'error' && !sameZcodeAccount + ? zcode + : this.applyStalePolicy(zcode, previousState.zcode) }) } } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index df36011ab31..55a2cc2a062 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -5,6 +5,7 @@ import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' import { fetchCursorRateLimits } from '../cursor-fetcher' import { readCursorAuthSession } from '../cursor-auth' +import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { createHash } from 'node:crypto' import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' @@ -44,6 +45,7 @@ export type FetchAllCyclePrepared = { ] grokResultPromise: Promise cursorResultPromise: Promise + zcodeResultPromise: Promise } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -130,7 +132,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), grok: this.withFetchingStatus(previousState.grok, 'grok'), - cursor: this.withFetchingStatus(previousState.cursor, 'cursor') + cursor: this.withFetchingStatus(previousState.cursor, 'cursor'), + zcode: this.withFetchingStatus(previousState.zcode, 'zcode') }) // Why: the Cursor probe reads the macOS Keychain, so it is awaited inside the @@ -145,6 +148,11 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (reason) => ({ status: 'rejected', reason }) as const ) + const zcodeResultPromise = fetchZcodeRateLimits({ signal }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + const missingWslCodexHome = codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) const grokResultPromise = fetchGrokRateLimits({ @@ -231,7 +239,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ miniMaxResult ], grokResultPromise, - cursorResultPromise + cursorResultPromise, + zcodeResultPromise } } } diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index e49c003cbb1..402495080c2 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -79,7 +79,8 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue minimax: this.state.minimax, grok: this.state.grok, antigravity: this.state.antigravity, - cursor: this.state.cursor + cursor: this.state.cursor, + zcode: this.state.zcode } return Object.entries(byProvider).map(([provider, limits]) => ({ provider: provider as ActiveRateLimitProvider, diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index a10bfa3f3c0..7af82c6c313 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -32,7 +32,8 @@ export abstract class RateLimitServiceState { antigravity: null, minimax: null, grok: null, - cursor: null + cursor: null, + zcode: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' // Why: the Cursor probe reads the macOS Keychain, so it cannot run synchronously @@ -52,7 +53,8 @@ export abstract class RateLimitServiceState { minimax: 0, grok: 0, antigravity: 0, - cursor: 0 + cursor: 0, + zcode: 0 } // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. protected activeFailureStreakByProvider: Record = { @@ -64,7 +66,8 @@ export abstract class RateLimitServiceState { minimax: 0, grok: 0, antigravity: 0, - cursor: 0 + cursor: 0, + zcode: 0 } protected mainWindow: BrowserWindow | null = null protected detachWindowListeners: (() => void) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 9783f37f77d..a3998c13abe 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -110,6 +110,7 @@ export type InternalRateLimitState = { minimax: ProviderRateLimits | null grok: ProviderRateLimits | null cursor: ProviderRateLimits | null + zcode: ProviderRateLimits | null } export function normalizePollingInterval(ms: number): number { diff --git a/src/main/rate-limits/zcode-usage-fetcher.test.ts b/src/main/rate-limits/zcode-usage-fetcher.test.ts new file mode 100644 index 00000000000..b587d196be0 --- /dev/null +++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts @@ -0,0 +1,292 @@ +import { mkdirSync, 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 { fetchZcodeRateLimits } from './zcode-usage-fetcher' + +let dir: string +let configPath: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-zcode-usage-')) + configPath = join(dir, 'config.json') + vi.stubGlobal('fetch', vi.fn()) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-12T06:00:00.000Z')) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) +}) + +function writeConfig(overrides: Record = {}): void { + mkdirSync(dir, { recursive: true }) + writeFileSync( + configPath, + JSON.stringify({ + model: { main: 'bigmodel-coding-plan/GLM-5.2' }, + provider: { + other: { options: { apiKey: 'ignored', baseURL: 'https://example.com/v1' } }, + 'bigmodel-coding-plan': { + options: { + apiKey: 'test-secret', + baseURL: 'https://open.bigmodel.cn/api/anthropic' + } + } + }, + ...overrides + }) + ) +} + +describe('fetchZcodeRateLimits', () => { + it('returns unavailable without a supported Coding Plan credential', async () => { + writeFileSync(configPath, JSON.stringify({ provider: {} })) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result).toMatchObject({ provider: 'zcode', status: 'unavailable' }) + expect(fetch).not.toHaveBeenCalled() + }) + + it('does not infer an account from the only configured provider without a selected model', async () => { + writeConfig({ + model: {}, + provider: { + zai: { options: { apiKey: 'sole-account', baseURL: 'https://api.z.ai/v1' } } + } + }) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('queries the matching quota endpoint and maps rolling, weekly, and MCP limits', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + level: 'max', + limits: [ + { + type: 'TIME_LIMIT', + unit: 5, + number: 1, + percentage: 3, + nextResetTime: 1_787_000_000_000 + }, + { + type: 'TOKENS_LIMIT', + unit: 6, + number: 1, + percentage: 44, + nextResetTime: 1_786_600_000_000 + }, + { + type: 'TOKENS_LIMIT', + unit: 3, + number: 5, + percentage: 12, + nextResetTime: 1_786_500_000_000 + } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(fetch).toHaveBeenCalledWith( + 'https://open.bigmodel.cn/api/monitor/usage/quota/limit', + expect.objectContaining({ + method: 'GET', + redirect: 'error', + headers: expect.objectContaining({ Authorization: 'test-secret' }) + }) + ) + expect(result).toMatchObject({ provider: 'zcode', status: 'ok', planType: 'max' }) + expect(result.session).toEqual({ + usedPercent: 12, + windowMinutes: 300, + resetsAt: 1_786_500_000_000, + resetDescription: null + }) + expect(result.weekly?.usedPercent).toBe(44) + expect(result.monthly?.usedPercent).toBe(3) + }) + + it('uses CREDIT_LIMIT counts when the reported percentage is stale', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + code: 200, + data: { + limits: [ + { + type: 'CREDIT_LIMIT', + unit: 3, + number: 5, + usage: 2_000, + currentValue: 500, + remaining: 1_500, + percentage: 1 + }, + { + type: 'CREDIT_LIMIT', + unit: 6, + number: 1, + usage: 10_000, + remaining: 8_000, + percentage: 0 + } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(25) + expect(result.weekly?.usedPercent).toBe(20) + expect(result.monthly).toBeNull() + }) + + it('drops an implausible five-hour reset without discarding the quota value', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + limits: [ + { + type: 'CREDIT_LIMIT', + unit: 3, + number: 5, + percentage: 25, + nextResetTime: Date.now() + 10 * 60 * 60_000 + } + ] + } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.session?.usedPercent).toBe(25) + expect(result.session?.resetsAt).toBeNull() + }) + + it('selects the legacy string model provider when several accounts are configured', async () => { + writeConfig({ + model: 'zai/GLM-5.3', + provider: { + zai: { options: { apiKey: 'selected-key', baseURL: 'https://api.z.ai/v1' } }, + bigmodel: { options: { apiKey: 'other-key', baseURL: 'https://open.bigmodel.cn/v1' } } + } + }) + vi.mocked(fetch).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { limits: [{ type: 'CREDIT_LIMIT', unit: 3, number: 5, percentage: 20 }] } + }) + ) + ) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('ok') + expect(fetch).toHaveBeenCalledWith( + 'https://api.z.ai/api/monitor/usage/quota/limit', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'selected-key' }) + }) + ) + }) + + it('does not substitute a different account when the selected provider lacks a key', async () => { + writeConfig({ + model: { main: 'unconfigured/GLM-5.3' }, + provider: { zai: { options: { apiKey: 'other-account', baseURL: 'https://api.z.ai/v1' } } } + }) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('rejects a nonstandard HTTPS port before sending the key', async () => { + writeConfig({ + provider: { zai: { options: { apiKey: 'secret', baseURL: 'https://api.z.ai:4444/v1' } } } + }) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('unavailable') + expect(fetch).not.toHaveBeenCalled() + }) + + it('supports the Z.AI endpoint without exposing credentials in errors', async () => { + writeConfig({ + model: { main: 'zai/GLM-5.2' }, + provider: { + zai: { options: { apiKey: 'never-log-me', baseURL: 'https://api.z.ai/api/anthropic' } } + } + }) + vi.mocked(fetch).mockResolvedValue(new Response('denied', { status: 401 })) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(fetch).toHaveBeenCalledWith( + 'https://api.z.ai/api/monitor/usage/quota/limit', + expect.any(Object) + ) + expect(result.status).toBe('error') + expect(result.error).toBe('ZCode quota request failed (401)') + expect(JSON.stringify(result)).not.toContain('never-log-me') + }) + + it('changes the non-secret account identity when the selected key changes', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue(new Response('denied', { status: 401 })) + + const first = await fetchZcodeRateLimits({ configPath }) + writeConfig({ + provider: { + 'bigmodel-coding-plan': { + options: { apiKey: 'new-key', baseURL: 'https://open.bigmodel.cn/api/anthropic' } + } + } + }) + const second = await fetchZcodeRateLimits({ configPath }) + + expect(first.usageMetadata?.authProvenance).toMatch(/^[a-f0-9]{64}$/) + expect(second.usageMetadata?.authProvenance).not.toBe(first.usageMetadata?.authProvenance) + expect(JSON.stringify(first)).not.toContain('test-secret') + expect(JSON.stringify(second)).not.toContain('new-key') + }) + + it('rejects malformed successful responses', async () => { + writeConfig() + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ success: true, data: {} }))) + + const result = await fetchZcodeRateLimits({ configPath }) + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + }) +}) diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts new file mode 100644 index 00000000000..852def603e3 --- /dev/null +++ b/src/main/rate-limits/zcode-usage-fetcher.ts @@ -0,0 +1,277 @@ +import { createHmac, randomBytes } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' + +const API_TIMEOUT_MS = 15_000 +const SUPPORTED_HOSTS = new Set(['api.z.ai', 'open.bigmodel.cn', 'dev.bigmodel.cn']) +const CREDENTIAL_IDENTITY_KEY = randomBytes(32) + +type QuotaLimit = { + type?: unknown + unit?: unknown + number?: unknown + usage?: unknown + currentValue?: unknown + remaining?: unknown + percentage?: unknown + nextResetTime?: unknown +} + +type ZcodeUsageCredentials = { + apiKey: string + quotaUrl: string + authProvenance: string +} + +// Why readers and not casts: both JSON sources are outside our control — a user-edited +// config file and a remote response — so their shape is a guess until something checks it. +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readRecord(value: unknown): Record | null { + return isRecord(value) ? value : null +} + +function readMainProvider(model: unknown): string | null { + const name = typeof model === 'string' ? model : readRecord(model)?.main + if (typeof name !== 'string') { + return null + } + const delimiter = name.indexOf('/') + return delimiter > 0 && delimiter < name.length - 1 ? name.slice(0, delimiter) : null +} + +function unavailable(error: string): ProviderRateLimits { + return { + provider: 'zcode', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error, + status: 'unavailable', + usageMetadata: { source: 'web', failureKind: 'missing-credentials' } + } +} + +function failed( + error: string, + failureKind: 'network' | 'server' | 'parse', + authProvenance: string +): ProviderRateLimits { + return { + provider: 'zcode', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error, + status: 'error', + usageMetadata: { source: 'web', failureKind, authProvenance } + } +} + +function readCredentials(configPath: string): ZcodeUsageCredentials | null { + let config: Record | null + try { + config = readRecord(JSON.parse(readFileSync(configPath, 'utf8'))) + } catch { + return null + } + + if (!config) { + return null + } + // A quota from another configured account must never appear as the selected model's quota. + const mainProvider = readMainProvider(config.model) + if (!mainProvider) { + return null + } + const options = readRecord(readRecord(readRecord(config.provider)?.[mainProvider])?.options) + const apiKey = options?.apiKey + const baseURL = options?.baseURL + if ( + typeof apiKey !== 'string' || + !apiKey.trim() || + /[\r\n]/.test(apiKey) || + typeof baseURL !== 'string' + ) { + return null + } + try { + const parsed = new URL(baseURL) + if ( + parsed.protocol !== 'https:' || + !SUPPORTED_HOSTS.has(parsed.hostname) || + (parsed.port !== '' && parsed.port !== '443') + ) { + return null + } + return { + apiKey: apiKey.trim(), + quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`, + authProvenance: createHmac('sha256', CREDENTIAL_IDENTITY_KEY) + .update(JSON.stringify([mainProvider, parsed.origin, apiKey.trim()])) + .digest('hex') + } + } catch { + return null + } +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function asUsedPercent(limit: QuotaLimit): number | null { + const total = asNumber(limit.usage) + if (total !== null && total > 0) { + const current = asNumber(limit.currentValue) + const remaining = asNumber(limit.remaining) + if (current !== null || remaining !== null) { + const used = current ?? total - (remaining ?? 0) + return Math.min(100, Math.max(0, (used / total) * 100)) + } + } + const reported = asNumber(limit.percentage) + return reported === null ? null : Math.min(100, Math.max(0, reported)) +} + +function asResetTime(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +function asWindowMinutes(limit: QuotaLimit): number | null { + if (limit.type === 'TIME_LIMIT' && limit.unit === 5 && limit.number === 1) { + // Z.ai's monthly MCP marker is encoded as one minute. + return 30 * 24 * 60 + } + const multipliers: Record = { 1: 1440, 3: 60, 5: 1, 6: 10080 } + const unit = asNumber(limit.unit) + const count = asNumber(limit.number) + if (unit === null || count === null || !Number.isInteger(count) || count <= 0) { + return null + } + const multiplier = multipliers[unit] + return multiplier ? count * multiplier : null +} + +function asWindow(limit: QuotaLimit | undefined): RateLimitWindow | null { + if (!limit) { + return null + } + const usedPercent = asUsedPercent(limit) + const windowMinutes = asWindowMinutes(limit) + if (usedPercent === null || windowMinutes === null) { + return null + } + const reset = asResetTime(limit.nextResetTime) + return { + usedPercent, + windowMinutes, + resetsAt: + windowMinutes === 300 && reset !== null && reset > Date.now() + 301 * 60_000 ? null : reset, + resetDescription: null + } +} + +export async function fetchZcodeRateLimits( + options: { + configPath?: string + signal?: AbortSignal + } = {} +): Promise { + const configPath = options.configPath ?? join(homedir(), '.zcode', 'cli', 'config.json') + const credentials = readCredentials(configPath) + if (!credentials) { + return unavailable('ZCode Coding Plan credentials are not configured') + } + + let response: Response + try { + const signal = options.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS) + response = await fetch(credentials.quotaUrl, { + method: 'GET', + redirect: 'error', + headers: { + Authorization: credentials.apiKey, + 'Accept-Language': 'en-US,en', + 'Content-Type': 'application/json' + }, + signal + }) + } catch (error) { + return failed( + error instanceof Error ? error.message : 'ZCode quota request failed', + 'network', + credentials.authProvenance + ) + } + + if (!response.ok) { + await cancelUnreadResponseBody(response) + return failed( + `ZCode quota request failed (${response.status})`, + 'server', + credentials.authProvenance + ) + } + + let payload: Record | null + try { + payload = readRecord(await response.json()) + } catch { + return failed('Could not parse ZCode quota response', 'parse', credentials.authProvenance) + } + const data = readRecord(payload?.data) + const code = payload?.code + const reported = data?.limits + if ( + payload?.success !== true || + (code !== undefined && code !== 0 && code !== 200) || + !Array.isArray(reported) + ) { + const msg = payload?.msg + const message = typeof msg === 'string' ? msg : 'Invalid ZCode quota response' + return failed(message, 'parse', credentials.authProvenance) + } + + const limits = reported.filter((value): value is QuotaLimit => isRecord(value)) + const planLimits = limits + .filter((limit) => limit.type === 'TOKENS_LIMIT' || limit.type === 'CREDIT_LIMIT') + .map(asWindow) + .filter((limit): limit is RateLimitWindow => limit !== null) + .sort((left, right) => left.windowMinutes - right.windowMinutes) + const session = planLimits.find((limit) => limit.windowMinutes === 300) ?? null + const weekly = planLimits.find((limit) => limit.windowMinutes === 10080) ?? null + const monthly = asWindow(limits.find((limit) => limit.type === 'TIME_LIMIT')) + if (!session && !weekly && !monthly) { + return failed( + 'ZCode quota response contained no usable limits', + 'parse', + credentials.authProvenance + ) + } + + return { + provider: 'zcode', + session, + weekly, + monthly, + planType: typeof data?.level === 'string' ? data.level : null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'web', + credentialSource: configPath, + authProvenance: credentials.authProvenance + } + } +} diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts index d1ff9ee24ca..8b552e55277 100644 --- a/src/main/remote-agent-trust-presets.test.ts +++ b/src/main/remote-agent-trust-presets.test.ts @@ -160,3 +160,66 @@ describe('markRemoteAgentWorkspaceTrusted', () => { expect(fsProvider.writeFile).not.toHaveBeenCalled() }) }) + +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 index a9ccb382c09..9ca8d665e06 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -1,3 +1,4 @@ +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' @@ -20,7 +21,9 @@ export async function markRemoteAgentWorkspaceTrusted(args: { } const workspacePath = await canonicalizeRemoteWorkspacePath(fsProvider, args.workspacePath) - if (args.preset === 'codex') { + 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) diff --git a/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.meta.json b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.meta.json new file mode 100644 index 00000000000..5c5284b5056 --- /dev/null +++ b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-23T07:34:39.531Z", + "platform": "darwin", + "command": ["dsh-tui"], + "cols": 120, + "rows": 40, + "note": "dsh-tui 0.10.2 on @deepseek-ai/dsh 0.1.5-rc.1, macOS arm64, no DEEPSEEK_API_KEY, empty workspace, DSH_TUI_LANG=en", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt new file mode 100644 index 00000000000..34f9432049a --- /dev/null +++ b/src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt @@ -0,0 +1 @@ +[?25l[?2004h[?1004h]11;?[>0q[?1049h[?1000h[?1002h[?1003h[?1006h]0;✦ 🐋 ]8;; ✦ dsh-TUI v0.10.2 █▀▀▀▄█▀▀▀▀█▀▀▀▀█▀▀▀▄█▀▀▀▀█▀▀▀▀█▀▀▀▀██ ███████████ ▄▀▄▄███▀▀▀█▀▀▀█▄▄▄▀▀▀▀▄█▀▀▀█▀▀▀██ ▀▀▀▀▄▄▄▀▀▀██████████ ▄▄▄▄▄▄▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀█▄▄▄▀█▄▄▄▄█▄▄▄▄██▄▄▄▀█▄▄▄▄█▄▄▄▄██ ▄▀▀▀▀▀▀▀▀▀▀▀▀▄▄▀▀▀▀▀▀▀▀▀██▄▀▄█▀▀▀▄███▀▀▀▀█▀▀▀▀█▀▀▀▀ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▀▀▀▀▀████████████ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀▀██▀▀▀██▄▄▄▀████▀▀▀▀▀▀▄▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀████████████ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀█████████▄▄▄▄█▄▄▄▀█▄▄▄▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀deepseek-flash · Max effort ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄/private/tmp/dsh-ws ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀Tip: Clicktool/thinking/summaryrowstofold;subagentcardsopendetail;… ⚠ The dsh engine (0.1.5-rc.3) is newer than the 0.1.5-rc.1 this UI is validated against, so issues are possible; downgrade via npm i -g @deepseek-ai/dsh@0.1.5-rc.1 for stability, or wait for a dsh-tui update. Explore the uncharted! ▶(Ctrl+P to expand)Contextloaded·Systemprompt18sections·Runtimecontext2items·Skills14·Tools27 ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ❯  ⛶ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ deepseek-flash · max · dsh-ws]8;; ✦ █ █ █ █ █ █ █ █ █ █ E]8;; ✦ █▀ █ █ █ █▄ █ █ █▀ █ █ Ex]8;; ✦ ds █▀▀▀ █ █ █ █▄▄▄ █ █ █▀▀▀ █ █ Expl]8;; ✦ dsh █▀▀▀▄ ██ ██ ██ █▄▄▄▀ ██ ██ █▀▀▀█ ██ ██ Explo]8;; ✦ dsh- █▀▀▀▄ ██ ██ ██ █▄▄▄▀ ██ ██ █▀▀▀█ ██ ██ Explor]8;; ▀▀]8;; ✦ dsh-T █▀▀▀▄█ ███ ███ ███ █▄▄▄▀█ ██ ███ █▀▀▀██ ███ ███ Explore]8;; ✦ dsh-TU █▀▀▀▄█▀ ███ ███▀ ███ █▄▄▄▀█▄ ██▄ ███ █▀▀▀██▀ ███ ███ Explore ]8;; ✦ dsh-TU █▀▀▀▄█▀ ███ ███▀ ███ █▄▄▄▀█▄ ██▄ ███ █▀▀▀██▀ ███ ███ Explore ]8;; ✦ dsh-TUI ▀▀▄█▀▀▀ ██ ██▀▀▀ ██ ▄▄▀█▄▄▄ █▄▀▄ ██ ▀▀██▀▀▀ ██ ██ Explore th]8;; ▀▀]8;; ✦ dsh-TUI ▀▀▄█▀▀▀▀ ██ ██▀▀▀ ██ ▄▄▀█▄▄▄▄ █▄▀▄ ███ ▀▀██▀▀▀█ ███ ███ Explore the]8;;  dsh-TUI ▀▄█▀▀▀▀ ██ ██▀▀▀ ██ ▄▀█▄▄▄▄ █▄▀▄ ███ ▀██▀▀▀█ ███ ███ xplore the ]8;; dsh-TUI ▄█▀▀▀▀█▀ ███ ██▀▀▀█▀ ███ ▀█▄▄▄▄█▄ █▄▀▄█▀ ████ ██▀▀▀██▄ ████ ████ plore the un]8;; h-TUI █▀▀▀▀█▀▀ ██ █▀▀▀█▀▀ ██ █▄▄▄▄█▄▄ ▄▀▄█▀▀ ███ █▀▀▀██▄▄ ████ ███ ore the unc]8;; - ▀▀▀▀█▀▀▀ █ ▀▀▀█▀▀▀ █ ▄▄▄▄█▄▄▄ ▄▀ ▀▄ re the unch]8;; ▄▄▄ ▀]8;; TUI ▀▀▀█▀▀▀▀ █ ▀▀█▀▀▀ █ ▄▄▄█▄▄▄▄ ▀▄█▀▀▀▄ ███ ▀▀██▄▄▄▀ ███ ███ e the uncha]8;; UI ▀▀█▀▀▀▀ █ ▀█▀▀▀ █ ▄▄█▄▄▄▄ ▄█▀▀▀▄ ███ ▀██▄▄▄▀ ███ ███  the unchar]8;; ▄ ▀▀▀▀▀]8;; I ▀█▀▀▀▀█ ██ █▀▀▀█ ██ ▄█▄▄▄▄█ █▀▀▀▄█ ████ ██▄▄▄▀█ ████ ████ the unchart]8;; █▀▀▀▀█▀ ██ █▀▀▀█▄ ██ █▄▄▄▄█ █▀▀▀▄█ ████ █▄▄▄▀█ ███ ███ he uncharte]8;; ▄▄▀▄▄ ▀▀▀▀]8;; █▀▀▀▀█▀ ██ █▀▀▀█▄ ██ █▄▄▄▄█ █▀▀▀▄█ ████ █▄▄▄▀█ ███ ███ e uncharte]8;; ▀▀█▀▀▀▄ ██ ▀█▄▄▄▀ █ ▄▄█ ▀▄██ ████ ▄▀███ ███ ███ ncharted!]8;; ▄ ▄▄▀▀▀▀▀▀▄▄ ▀ ▀▀ ▀ ▄]8;; ▀▀█▀▀▀▄ ██ ▀█▄▄▄▀ █ ▄▄█ ▀▄██ ████ ▄▀███ ███ ███ ncharted!]8;; ▀█▀▀▀▄█ ███ █▄▄▄▀ █ ▄██ ▄███ █████ ▀████ ████ ████ charted!]8;; █▀▀▀▄█▀ ███ █▄▄▄▀▀ █ ██▄ ███▀ ████ ████▀ ████ ███▄ harted!]8;; ▄▄▄▄ ▄▀▀▀▀▄▄▀▀▀▀▄ ▀  ▀  ▀]8;; █▀▀▀▄█▀▀ ███ █▄▄▄▀▀▀ █ ██▄▄ ███▀▀ ████ ████▀▀ ████ ███▄▄ arted!]8;; ▀▀▀▄█▀▀▀ ██ ▄▄▄▀▀▀▀ █▄▄▄ ██▀▀ ███ ███▀▀ ███ ██▄▄ rted!]8;; ▄  ▄ ▄▀  ▀▄ ▀  ▀]8;; ▀▀▄█▀▀▀▀ ██ ▄▄▀▀▀▀▄ █ █▄▄▄▀ ██▀▀▀▀ ██ ███▀▀▀ ███ ██▄▄▄▄ ted!]8;; ✦ ▀▄█▀▀▀▀ ██ ▄▀▀▀▀▄ █ █▄▄▄▀ ██▀▀▀▀ ██ ██▀▀▀ ███ ██▄▄▄▄ ed!]8;;          ]8;; ✦ d ▄█▀▀▀▀█▀ ███ ▀▀▀▀▄█▀ ██ █▄▄▄▀█▄ ██▀▀▀▀█▀ ███ ██▀▀▀▀ ██ ██▄▄▄▄█▄ d!]8;; ✦ ds █▀▀▀▀█▀▀ ██ ▀▀▀▄█▀▀ ██ █▄▄▄▀█▄▄ █▀▀▀▀█▀ ██ █▀▀▀▀ █ █▄▄▄▄█▄]8;; ✦ ds ▀▀▀▀█▀▀ █ ▀▀▀▄█▀▀ ██ ▄▄▄▀█▄▄ ▀▀▀▀█▀ █ ▀▀▀▀ ▄▄▄▄█▄]8;; ✦ dsh- ▀▀█▀▀▀▀ █ ▀▄█▀▀▀ ██ ▄▀█▄▄▄▄ ▀▀ ▀ ▄▄]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;; ✦ dsh-T ▀▀█▀▀▀▀ █ ▀▄█▀▀▀ ██ ▄▀█▄▄▄▄ ▀▀█▀▀ █ ▀▀▀ ▄▄█▄▄]8;; ✦ dsh-TU ▀█▀▀▀▀█ ██ ▄█▀▀▀█ ███ ▀█▄▄▄▄█ ▀█▀▀█ ██ ▀▄ █ ▄█▄▀█]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;; ✦ dsh-TUI █▀▀▀▀█▀ ██ █▀▀▀█▀ ██ █▄▄▄▄█▄ █▀▀▀▀█▀ ██ ▀▀▀▄▀ █ █▄▄▄▀█▄]8;; ✦ dsh-TUI █▀▀▀▀█▀▀ ██ █▀▀▀█▀▀ ██ █▄▄▄▄█▄▄ █▀▀█▀▀ ██ ▀▀▀▀ █▄▄█▄▄]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;; ✦ dsh-TUI ▀▀▀▀█▀▀▀▀ █ ▀▀▀█▀▀▀ █ ▄▄▄▄█▄▄▄▄ ▀▀▀▀▀ ▀▀▀▀▄ █ ▄▄▄▄▀]8;; dsh-TUI ▀▀█▀▀▀▀ █ ▀█▀▀▀ █ ▄▄█▄▄▄▄ ▀█▀▀▀▀ █ ▀▀▀▀▄ █ ▄█▄▄▄▀]8;; sh-TUI ▀█▀▀▀▀█ ██ █▀▀▀█ ██ ▄█▄▄▄▄█ ▀█▀▀▀▀ █ ▄▀▀▀▄ ██ ▀█▄▄▄▀ E]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; h-TUI █▀▀▀▀█ ██ █▀▀▀██ ██ █▄▄▄▄█ █▀▀▀▀ █ ▀▀▀▄ █ █▄▄▄▀ Ex]8;; -TUI █▀▀▀▀█ ███ █▀▀▀██ ███ █▄▄▄▄█ █▀▀▀▀ █ ▀▀▀▄ █ █▄▄▄▀ Exp]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀  ]8;; TUI ▀▀▀▀█ ██ ▀▀▀██ ██ ▄▄▄▄█ ▀▀▀▀ ▀▀▀▄ █ ▄▄▄▀ Expl]8;; UI ▀▀▀██ ██ ▀▀██ ██ ▄▄▄██ Explo]8;; I ▀▀██ ██ ▀██ ██ ▄▄██ ▀ ▄ █ ▀ Explor]8;; ▄▀▄ ▄ ▀▀▀▀ ▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▄▀▀▀ ▀▀▀ ▀ ▀  ]8;; ▀██ ██ ██ ██ ▄██ ▀ ▄ █ ▀ Explore]8;; ██ ██ ██ ██ ██ Explore t]8;; ▄▀ ▄ ▀▀▀▀▄ ▄▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀  ▀▀▀ ▀▀▀ ▀▀]8;; █ █ █ █ █ Explore th]8;; █ █ █ █ Explore the]8;; ▄▀▄ ▄ ▀▀▀▄ ▄▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀ ▄▀▀▀ ▀▀ ▀ ▀ ]8;; █ █ xplore the ]8;; █ █ plore the u]8;; lore the un]8;; ore the unc]8;; re the unch]8;; e the unch]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀ ▀▀▀▄  ▀]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀  ]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▄▀▄ ▄ ▀▀▀▀ ▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▄▀▀▀ ▀▀▀ ▀ ▀▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;; ▀▀]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀]8;; ▄▀ ▄ ▀▀▀▀▄ ▄▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀  ▀▀▀ ▀▀▀ ▀▀]8;; ▀▀ ▀▀▀▄  ▀]8;; ▄▀▄ ▄ ▀▀▀▄ ▄▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀ ▄▀▀▀ ▀▀ ▀ ▀ ]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀ ▀▀▀▄  ▀]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀  ]8;; ▀▀]8;; ▀ ▀▀▀▀▄ ▀▀▀▀▀  ]8;; ▀▀]8;; ▀ ▀▀▀▀ ▀▀▀▄ ▀]8;; ▄▀▄ ▄ ▀▀▀▀ ▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▄▀▀▀ ▀▀▀ ▀ ▀  ]8;; ▀▀▀▀ ▀▀▀▀ ▄▀▄▄]8;; ▀▀ ▀▀▀▄  ▀]8;; ▄▀ ▄ ▀▀▀▀▄ ▄▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀  ▀▀▀ ▀▀▀ ▀▀]8;; ▄▄▄▄  ▄  ▀▀▀▀ ▀▀▀▀]8;; ▄▀▄ ▄ ▀▀▀▄ ▄▀▀▀ ▀▀▀▀▀▀▀ ▀▀▀▀ ▄▀▀▀ ▀▀ ▀ ▀ ]8;;  ▀▀▀▀ ▀▄▄ ▄▄▄▄ ▄▄▄]8;;  ▄  ▀▀▀▀  ▀▀▀▀]8;; ▀▄▄ ▄▄▄▄  ▄ ▀▀▀]8;;  ▀▀▀▀  ▀▀▀▀ ▀▄▄ ▄▄▄ ]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;; ▄▄▄▄  ▄  ▀▀▀▀ ▀▀▀▀]8;; ▀ ▀▀▀▄▄ ▀▀▀▀▀  ]8;;  ▀▀▀▀ ▀▄▄ ▄▄▄▄ ▄▄▄]8;; ▀ ▀▀▀ ▀▀▀▄ ▀]8;; ▀▀ ▀▀▀▄  ▀]8;;  ▄  ▀▀▀▀  ▀▀▀▀]8;; ▀▄▄ ▄▄▄▄  ▄ ▀▀▀]8;;  ▀▀▀▀  ▀▀▀▀ ▀▄▄ ▄▄▄ ]8;;  ▄▀▄ ▄  ▀▀▀▄ ▄▀▀▀  ▀▀▀▀▀▀  ▀▀▀▀  ▄▀▀▀ ▀▀▀ ▀▀ ▀▀]8;; ▄▄▄▄  ▄  ▀▀▀▀ ▀▀▀▀]8;;  ▀▄ ▄  ▀▀▀▀ ▄▀▀▀  ▀▀▄▀▀▀▀▀▀  ▀▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀ ▀▀]8;;  ▀▀▀▀ ▀▄▄ ▄▄▄▄ ▄▄▄]8;;  ▄  ▀▀▀  ▀ ▄▀▀▄ ▀▄▀▀▀▀▀  ▀▀▀▀▀ ▀▀▀▀▀ ▀▀ ▀▀ ▀]8;;  ▄  ▀▀▀▀  ▀▀▀▀]8;; ▀▀]8;; ▀▀ ▀▀▀▀▄ ▀ ]8;;   ▄▀▄  ▀▀▀▀  ▀▀▀▀ ▄  ▄▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀▀▀ ▀]8;; ▀▀]8;; ▀ ▀▀▀▀▀ ▀▀▀▀▀  ]8;; ▀▄▄ ▄▄▄▄  ▄ ▀▀▀]8;; ▄ ▀▀▀ ▀▀▀▀▄ ▄▀▀▄ ▀▀▀▀▄▀▀▀▀▀ ▄▄▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀▀▀ ▄ \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/freebuff-lifecycle.meta.json b/src/main/runtime/__fixtures__/freebuff-lifecycle.meta.json new file mode 100644 index 00000000000..57f6174485f --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-lifecycle.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T07:56:49.813Z", + "platform": "darwin", + "command": ["freebuff", "--cwd", "/tmp/orca-freebuff-folder-workspace"], + "cols": 120, + "rows": 40, + "note": "Freebuff 0.1.2 authenticated free plan; choice, answer, tool, interruption, tool error", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/freebuff-lifecycle.txt b/src/main/runtime/__fixtures__/freebuff-lifecycle.txt new file mode 100644 index 00000000000..0f965581bce --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-lifecycle.txt @@ -0,0 +1 @@ +]11;?]10;?[?2031h]10;?]11;?[>0q[?25l[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?[?2026h[?25l ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   ┌──────────────────────────────────────────────────────────────────┐  │ Your first message starts the session. │  │ Some models aren't available on this connection │  └──────────────────────────────────────────────────────────────────┘                       ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-folder-workspace · /model to change · Chat: New chat  ← for history · ? for help [?2026l[?1004h[?2026h[?25l│ 1 day streak │└──────────────────────────────────────────────────────────────────┘[?2026l[?2026h[?25l100/100 Freebucks remaining │ ││✦ Refer friends → earn Freebucks:││││⎘ Copy invite link Learn More ↵│└──────────────────────────────────────────────────────────────────┘[?2026l[?2026h[?25l ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   ┌──────────────────────────────────────────────────────────────────┐  │ Your first message starts the session. │  │ 100/100 Freebucks remaining │  │ 1 day streak │  │ │  │ ✦ Refer friends → earn Freebucks: │  │ │  │ ⎘ Copy invite link Learn More ↵ │  └──────────────────────────────────────────────────────────────────┘                  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-folder-workspace · /model to change · Chat: New chat  ← for history · ? for help [?2026l[?2026h[?25l╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│Geo Daniela — La primera IA de remuneraciones de Chile Ad││Liquidaciones PDF, Previred CSV, LRE CSV y portal del trabajador. Demo 30 min para PYME.││ Learn more geodaniela.cl│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25lUse ask_user to ask me to choose blue orgreen.Waitformyreply.Donotmodifyfiles.▍[?2026l]0;Freebuff: Use ask_user to ask me to choose blue or green. W…[?2026h[?25l ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│Starting your model session… Your message is saved.        ││Esc: back to draft│└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘[?2026l[?2026h[?25lSession active └──────────────────────────────────────────────────────────────────┘    [12:55 AM]  Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│Geo Daniela — La primera IA de remuneraciones de Chile Ad││ Liquidaciones PDF, Previred CSV, LRE CSV y portal del trabajador. Demo 30 min para PYME. │ Learn more  geodaniela.cl  ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 44m left  ✕ End session ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-folder-workspace · /model to change · Chat: Use ask_user to ask me to choose blue or green.Wait for my reply. Do not modify files. [?2026l[?2026h[?25lthinking... ■ Esc[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25l1s[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25l2[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25l3[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25l4[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25l5[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25l6[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lworking... [?2026l[?2026h[?25l• ThinkingThe user wants me to ask them to choose between blue and green using the ask_user tool. This is straightforward — I should call ask_user with a single question offering those two options, then wait for their reply. No file modifications needed.[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l ▀▀ ██▀███▄▄▄████▄▄▄███▀███████ ██  ███ ██████▀▀▀▀▀██▀▀▀▀▀▄▄████▄██ ▀ ▀ ▀▀▀▀ ▀███▀    ┌──────────────────────────────────────────────────────────────────┐│ Session active ││100/100 Freebucks remaining││ 1 day streak │└──────────────────────────────────────────────────────────────────┘    [12:55 AM]    Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘ • Thinking   The user wants me to ask them to choose between blue and green using the ask_user tool. This is straightforward — I should call ask_user with a single question offering those two options, then wait for  their reply. No file modifications needed. ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│Geo Daniela — La primera IA de remuneraciones de Chile Ad││Liquidaciones PDF, Previred CSV, LRE CSV y portal del trabajador. Demo 30 min para PYME.││ Learn more geodaniela.cl│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  6s ╭─────────────────────────────────────────────── Some questions for you ───────────────────────────────────────────────╮│Close ✕││ ││▼ Which color would you like to choose?   ││ ○ Blue ││ Pick blue as the color ││ ○ Green ││ ○ Custom ││ ││╭──────────╮│││ Submit │ ↑↓ navigate • Enter select││╰──────────╯│╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25l7[?2026l[?2026h[?25l [?2026l[?2026h[?25l█████      ▄███ ▄██▀▀▀▀▀▀▀▀       ▀███████▄▄▄▄██ ████████████ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ █████ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██ ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██    ┌──────────────────────────────────────────────────────────────────┐│ Session active ││100/100 Freebucks remaining││1 day streak│└──────────────────────────────────────────────────────────────────┘   [12:55 AM] Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘  • Thinking The user wants me to ask them to choose between blue and green using the ask_user tool. This is  straightforward — I should call ask_user with a single question offering those two options, then wait for    their reply. No file modifications needed.      ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Geo Daniela — La primera IA de remuneraciones de Chile  Ad │ │ Liquidaciones PDF, Previred CSV, LRE CSV y portal del trabajador. Demo 30 min para PYME. │  │  Learn more  geodaniela.cl │  ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯   7s ╭─────────────────────────────────────────────── Some questions for you ───────────────────────────────────────────────╮ Close ✕ ▶ Which color would you like to choose? ↳ "Blue"╭──────────╮│Submit│╰──────────╯[?2026l[?2026h[?25l              ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮  │ Geo Daniela — La primera IA de remuneraciones de Chile  Ad │  │Liquidaciones PDF, Previred CSV, LRE CSV y portal del trabajador. Demo 30 min para PYME. │  │ Learn more geodaniela.cl│  ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  working...  7s ■ Esc ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25l ▀ ██▀▀█████▀████▀████▀▀████▀█ ██  ┌──────────────────────────────────────────────────────────────────┐│ Session active ││ 100/100 Freebucks remaining ││ 1 day streak │└──────────────────────────────────────────────────────────────────┘  [12:55 AM]   Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘    • Thinking The user wants me to ask them to choose between blue and green using the ask_user tool. This is   straightforward — I should call ask_user with a single question offering those two options, then wait for   their reply. No file modifications needed.  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││Your answer: ││││1. Which color would you like to choose?││↳ Blue│││╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l8[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l9[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l10[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l└──────────────────────────────────────────────────────────────────┘  [12:55 AM]  Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘    • Thinking   The user wants me to ask them to choose between blue and green using the ask_user tool. This is straightforward — I should call ask_user with a single question offering those two options, then wait for   their reply. No file modifications needed.  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││Your answer: ││ ││1. Which color would you like to choose?││↳ Blue││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯     • Thinking   The user chose Blue. They asked me to wait for their reply and not modify files. I've received their answer.   I should acknowledge it simply and not do anything else.  You chose Blue. Noted — and I haven't modified any files, as requested.[?2026l[?2026h[?25l   [12:55 AM] Use ask_user to ask me to choose blue or green. Wait for my reply. Do not modify files. ⎘    • Thinking  The user wants me to ask them to choose between blue and green using the ask_user tool. This is  straightforward —I should call ask_ur with asingle question offeringthose two ptions, then wait for  their reply. Nofile mdifition needed.     ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Your answer:   1. Which color would you like to choose?  ↳ Blue    ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯    • Thinking  The user chose Blue. They asked me to wait for their reply and not modify files. I've received their answer.  I shouldacknowledgeit simply andnt do anything els.    You chose Blue. Noted — and I haven't modified any files, as requested.█ ⎘•20s•△▽█44m left · 13.2K (1%)✕ End session[?2026l[?2026h[?25lThe user wants me to ask them to choose between blue and green using the ask_user tool. This is   straightforward — I should call ask_user with a single question offering those two options, then wait for   their reply. No file modifications needed.  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││Your answer: ││││1. Which color would you like to choose?││ ↳ Blue │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯     ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Looooooong running AI agentsAd│ │ Build and run AI agents that don't time out on Trigger.dev trigger.dev ↗ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ [?2026l[?2026h[?25lRun sleep 20 then say done. Do not modify files.▍[?2026l]0;Freebuff: Run sleep 20 then say done. Do not modify files.[?2026h[?25l  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│││Your answer:││ │1. Which color would you like to choose? ↳ Blue ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯     ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰╯    • Thinking    The user chose Blue. They asked me to wait for their reply and not modify files. I've received their answer.  I should acknowledge it simply and not do anything else. You chose Blue. Noted — and I haven't modified any files, as requested.  ⎘•20s•△▽  [12:56 AM]  Run sleep 20 then say done. Do not modify files. ⎘     ▍Enter a coding task or / for commands [?2026l[?2026h[?25l▸ Thinking    ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮   Your answer:   1. Which color would you like to choose? ↳ Blue │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯    ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ Looooooong running AI agents  Ad │ Build and run AI agents that don't time out on Trigger.dev trigger.dev ↗ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯   ▸ Thinking    You chose Blue. Noted — and I haven't modified any files, as requested.  ⎘•20s•△▽        [12:56 AM] Run sleep 20 then say done. Do not modify files. ⎘    thinking...  ■ Esc[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25l1s[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25l2[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25l3[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25l4[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25l5[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25l [?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25l6[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25l7[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25lworking... [?2026l[?2026h[?25l│ Your answer:││││1. Which color would you like to choose?││ ↳ Blue │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯     ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Looooooong running AI agentsAd│ │ Build and run AI agents that don't time out on Trigger.dev trigger.dev ↗ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯▸ Thinking     You chose Blue. Noted — and I haven't modified any files, as requested.   ⎘ • 20s • △▽  [12:56 AM] Run sleep 20 then say done. Do not modify files. ⎘ • Thinking     The user wants me to run `sleep 20` in the terminal and then say "done". This is a simple, harmless command —   it just waits 20 seconds. No files modified. The tool guidance says to ask permission for commands other   than the listed use cases, but this is trivially safe and the user explicitly requested it, which is explicit permission itself. Let[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l me run it with an appropriate timeout (default is[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l ↳ Blue ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯     ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰╯    ▸ Thinking    You chose Blue. Noted — and I haven't modified any files, as requested. ⎘•20s•△▽  [12:56 AM]  Run sleep 20 then say done. Do not modify files. ⎘      • Thinking   The user wants me to run `sleep 20` in the terminal and then say "done". This is a simple, harmless command — it just waits 20 seconds. No files modified. The tool guidance says to ask permission for commands other  than the listed use cases, but this is trivially safe and the user explicitly requested it, which is explicit prmission itself. Let me run it withan appopriate timeout (default is 30, which is fine for a 20-second sleep).  $ sleep 20 8[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l       ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮  │Looooooong running AI agentsAd│ │Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│ ╰╯      ▸ Thinking      You chose Blue. Noted — and I haven't modified any files, as requested.  ⎘•20s•△▽    [12:56 AM]   Run sleep 20 then say done. Do not modify files. ⎘        • Thinking    The user wants me to run `sleep 20` in the terminal and then say "done". This is a simple, harmless command —  it just waits 20 seconds. No files modified. The tool guidance says to ask permission for commands other   than the listed use cases, but this is trivially safe and the user explicitly requested it, which is explicit  prmission itself. Let me run it withan appopriate timeout (default is 30, which is fine for a 20-second  sleep).    $ sleep 20    [response interrupted]  ⎘•△▽▄44m left · 13.2K (1%)✕ End session[?2026l[?2026h[?25l╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯▸ Thinking     You chose Blue. Noted — and I haven't modified any files, as requested.   ⎘ • 20s • △▽   [12:56 AM] Run sleep 20 then say done. Do not modify files. ⎘ • Thinking     The user wants me to run `sleep 20` in the terminal and then say "done". This is a simple, harmless command —   it just waits 20 seconds. No files modified. The tool guidance says to ask permission for commands other   than the listed use cases, but this is trivially safe and the user explicitly requested it, which is explicit permission itself. Let me run it with an appropriate timeout (default is 30s, which is fine for a 20-second  sleep).  $ sleep 20  ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25lRun sh -c "exit 7" and report the result. Do not modify files.▍[?2026l]0;Freebuff: Run sh -c "exit 7" and report the result. Do not …[?2026h[?25l You chose Blue. Noted — and I haven't modified any files, as requested. ⎘•20s•△▽  [12:56 AM]  Run sleep 20 then say done. Do not modify files. ⎘      • Thinking   The user wants me to run `sleep 20` in the terminal and then say "done". This is a simple, harmless command — it just waits 20 seconds. No files modified. The tool guidance says to ask permission for commands other  than the listed use cases, but this is trivially safe and the user explicitly requested it, which is explicit prmission itself. Let me run it withan appopriate timeout (default is 30, which is fine for a 20-second sleep).  $ sleep 20  ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰╯    [response interrupted]    ⎘ • △▽   [12:56 AM]  Run sh -c "exit 7" and report the result. Do not modify files. ⎘   ▍Enter a coding task or / for commands [?2026l[?2026h[?25l│Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯       ▸ Thinking     You chose Blue. Noted — and I haven't modified any files, as requested. ⎘•20s•△▽    [12:56 AM]   Run sleep 20 then say done. Do not modify files. ⎘    ▸ Thinking    $ sleep 20   ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Looooooong running AI agents Ad │ Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯   [response interrupted]  ⎘•△▽      [12:56 AM] Run sh -c "exit 7" and report the result. Do not modify files. ⎘    thinking...  ■ Esc[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25l1s[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lhinking...[?2026l[?2026h[?25l2[?2026l[?2026h[?25ltinking...[?2026l[?2026h[?25lthnking...[?2026l[?2026h[?25lthiking...[?2026l[?2026h[?25lthining...[?2026l[?2026h[?25lthinkng...[?2026l[?2026h[?25lManaged Postgresthat keepsupwith your machins Scale your production wokloads withTigerData'smanaged, unfked PstgreSQL ndhigh-availability multi-AZ…SttFreTrial  tigerdata.com[?2026l[?2026h[?25lthinkig...[?2026l[?2026h[?25l3[?2026l[?2026h[?25lthinkin...[?2026l[?2026h[?25lthinking..[?2026l[?2026h[?25lworking.. [?2026l[?2026h[?25lYou chose Blue. Noted — and I haven't modified any files, as requested.   ⎘ • 20s • △▽  [12:56 AM] Run sleep 20 then say done. Do not modify files. ⎘ ▸ Thinking      $ sleep 20   ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[response interrupted]   ⎘• △▽      [12:56 AM]  Run sh -c "exit 7" and report the result. Do not modify files. ⎘ • Thinking   The user wants me to run `sh -c "exit 7"` and report the result. This is a harmless command — it just exits   with code 7. It doesn't modify files. Let me run it with the run_terminal_command tool.  $ sh -c "exit 7"[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l  [12:56 AM]  Run sleep 20 then say done. Do not modify files. ⎘      ▸ Thinking   $ sleep 20 ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Looooooong running AI agentsAd││Build and run AI agents that don't time out on Trigger.devtrigger.dev ↗│╰╯    [response interrupted]    ⎘ • △▽  [12:56 AM]  Run sh -c "exit 7" and report the result. Do not modify files. ⎘    • Thinking   The user wants me to run `sh -c "exit 7"` and report the result. This is a harmless command — it just exits with code 7. It doesn't modify files. Let me run it with the run_terminal_command tool. $ sh -c "exit 7" command: sh -c "exit 7" stdout: exitCode: 7 [?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l4[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l5[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l6[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l7[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l8[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25lworking...[?2026l[?2026h[?25l9[?2026l[?2026h[?25l   [response interrupted] ⎘•△▽  [12:56 AM]  Run sh -c "exit 7" and report the result. Do not modify files. ⎘• Thinking  The user wants me to run `sh -c "exit 7"` and report the result. This is a harmless command — it just exits    with code 7. It doesn't modify files. Let me run it with the run_terminal_command tool.   $ sh -c "exit 7"command: sh -c "exit 7"stdout:    exitCode: 7   • Thinking The command `sh -c "exit 7"` was run. It exited with code 7, no stdout output. I should report the result. No  files modified. The command ran and completed with: - Exit code: 7 (as expected, since  exit 7  was explicitly called)- stdout: empty - stderr: none  No files were modified.[?2026l[?2026h[?25l[response interrupted]  ⎘•△▽      [12:56 AM] Run sh -c "exit 7" and report the result. Do not modify files. ⎘    • Thinking  The user wants me to run `sh -c "exit 7"` and report the result. This is a harmless command — it just exits  with code 7. It doesn't modify files.Letme run it withthe run_terminal_command tool.    $ sh -c "exit 7" command: sh -c "exit 7" stdout:   exitCode: 7   • Thinking  The command `sh -c "exit 7"` was run. It exited with code 7, no stdout output. I should report the result. No  files odified.    The command ran and completed with: - Exit code: 7 (s expectd, since  exit 7  was explicitly called) stdout: empty  errnone    No files were modified.  ⎘•9s•△▽▄43m left · 13.3K (1%)✕ End session[?2026l[?2026h[?25l • Thinking   The user wants me to run `sh -c "exit 7"` and report the result. This is a harmless command — it just exits   with code 7. It doesn't modify files. Let me run it with the run_terminal_command tool.  $ sh -c "exit 7"command: sh -c "exit 7"stdout:  exitCode: 7 ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Your AI Agent’s Data Layer, Built on MongoDB AtlasAd││ Power your agentic workflows with MongoDB Atlas for reliable, scalable session memory and real-…mongodb.com ↗│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l[?2026h[?25l [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/freebuff-login.meta.json b/src/main/runtime/__fixtures__/freebuff-login.meta.json new file mode 100644 index 00000000000..390bcedba90 --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-login.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T07:20:19.119Z", + "platform": "darwin", + "command": ["freebuff", "--cwd", "/tmp/orca-freebuff-verify"], + "cols": 100, + "rows": 32, + "note": "Freebuff npm 0.1.2, unauthenticated, macOS arm64; no model inference", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/freebuff-login.txt b/src/main/runtime/__fixtures__/freebuff-login.txt new file mode 100644 index 00000000000..d90d8905ceb --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-login.txt @@ -0,0 +1 @@ +]11;?]10;?[?2031h]10;?]11;?[>0q[?25l[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?[?2026h[?25l ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   ┌──────────────────────────────────────────────────────────────────┐  │ Your first message starts the session. │  │ Some models aren't available on this connection │  └──────────────────────────────────────────────────────────────────┘           Try one of these:  → Explain this codebase  → Find opportunities to refactor  → Improve my test coverage  ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-verify · /model to change · Chat: New chat  ← for history · ? for help [?2026l[?1004h[?2026h[?25l             ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   Press ENTER to login...             [?2026l[?2026h[?25l             ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   Press ENTER to login...             [?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/freebuff-ready.meta.json b/src/main/runtime/__fixtures__/freebuff-ready.meta.json new file mode 100644 index 00000000000..4555254cd8f --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T08:15:15.356Z", + "platform": "darwin", + "command": ["freebuff", "--cwd", "/tmp/orca-freebuff-trust"], + "cols": 120, + "rows": 40, + "note": "Freebuff 0.1.2 authenticated, declined repository agent files; waiting for first message", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/freebuff-ready.txt b/src/main/runtime/__fixtures__/freebuff-ready.txt new file mode 100644 index 00000000000..150521f88b9 --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-ready.txt @@ -0,0 +1,11 @@ +]11;?]10;? +freebuff found agent files in this repository it has not run before: + + /private/tmp/orca-freebuff-trust/.agents + agents: demo.ts + +These run with your permissions and can read your environment. Only load them if you trust this repository. + +Load and run these? [y/N] n +Skipped agents and mcp.json in /private/tmp/orca-freebuff-trust/.agents for this run. Re-run and answer y to trust it, or set CODEBUFF_TRUST_AGENT_DIRS=1 (or pass --trust-agents). +[?2031h]10;?]11;?[>0q[?25l[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?[?2026h[?25l ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   ┌──────────────────────────────────────────────────────────────────┐  │ Your first message starts the session. │  │ Some models aren't available on this connection │  └──────────────────────────────────────────────────────────────────┘                       ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-trust · /model to change · Chat: New chat  ← for history · ? for help [?2026l[?1004h[?2026h[?25l95/100 Freebucks remaining │ ││✦ Refer friends → earn Freebucks:││││⎘ Copy invite link Learn More ↵│└──────────────────────────────────────────────────────────────────┘[?2026l[?2026h[?25l1 day streak ✦ Refer friends → earn Freebucks: │ ⎘ Copy invite link Learn More ↵ │└──────────────────────────────────────────────────────────────────┘[?2026l[?2026h[?25l ███████████ ██ ▄████ ▄██  ███▀▀▀▀▀▀▀▀ ██ ██ ██▀  ███ █████ ██████ ▄█████ ▄████▄ ██████▄ ██ ██ ████████████  ███ ███▀▀ ██▀ ███▄▄▄██ ██▄▄▄███ ██ ▀██ ██ ██ ██ ██  ███ ███ ███ ███▀▀▀▀▀ ██▀▀▀▀▀ ██▄ ▄██ ██▄ ██ ██ ██  ███ ██▀ ██▀ ▀█████ ▀████▀ ██████▀ ▀████▀ ██ ██   ┌──────────────────────────────────────────────────────────────────┐  │ Your first message starts the session. │  │ 95/100 Freebucks remaining │  │ 1 day streak │  │ │  │ ✦ Refer friends → earn Freebucks: │  │ │  │ ⎘ Copy invite link Learn More ↵ │  └──────────────────────────────────────────────────────────────────┘                  ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ ▍Enter a coding task or / for commands ││ │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ GLM 5.3 Flash • max · /private/tmp/orca-freebuff-trust · /model to change · Chat: New chat  ← for history · ? for help [?2026l[?2026h[?25l╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│MARS | A robot to build physical AI agents Ad││A mobile robot with an arm to run agents for $995. Start in simulation. Bring it to life.││ Learn more innate.bot│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/freebuff-trust.meta.json b/src/main/runtime/__fixtures__/freebuff-trust.meta.json new file mode 100644 index 00000000000..d4513cba70e --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-trust.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T08:09:19.020Z", + "platform": "darwin", + "command": ["freebuff", "--cwd", "/tmp/orca-freebuff-trust"], + "cols": 120, + "rows": 40, + "note": "Freebuff 0.1.2; harmless repository agent file trust prompt, not accepted", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/freebuff-trust.txt b/src/main/runtime/__fixtures__/freebuff-trust.txt new file mode 100644 index 00000000000..48ca2e77160 --- /dev/null +++ b/src/main/runtime/__fixtures__/freebuff-trust.txt @@ -0,0 +1,9 @@ +]11;?]10;? +freebuff found agent files in this repository it has not run before: + + /private/tmp/orca-freebuff-trust/.agents + agents: demo.ts + +These run with your permissions and can read your environment. Only load them if you trust this repository. + +Load and run these? [y/N] \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/qoder-no-account.meta.json b/src/main/runtime/__fixtures__/qoder-no-account.meta.json new file mode 100644 index 00000000000..e434627627c --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-no-account.meta.json @@ -0,0 +1,17 @@ +{ + "capturedAt": "2026-09-28T08:03:27.131Z", + "platform": "darwin", + "command": [ + "qodercli", + "--config-dir", + "/tmp/orca-qoder-probe/config", + "--cwd", + "/tmp/orca-qoder-probe/workspace", + "--prompt-interactive", + "Reply with hello." + ], + "cols": 100, + "rows": 32, + "note": "Qoder CLI 1.1.64; isolated trusted folder; no account; lifecycle hook capture", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-no-account.txt b/src/main/runtime/__fixtures__/qoder-no-account.txt new file mode 100644 index 00000000000..9c675342559 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-no-account.txt @@ -0,0 +1,66 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted────────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLIv1.1.64│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████NotLoginPleaseAuth│4.Bespecificforthebestresults│ +╰───────────────────────────────────────────────────────────╯ + + + Authenticating +──────────────────────────────────────────────────────────────────────────────────────────────────── + +⠋Waitingforauthentication... + +Escback +[>0q Welcome to Qoder CLI Signintogetstarted,orexittheapplication.  ❯1.Signintocontinue  2. Exittheapplication + +↑/↓navigate·Enterselect·Escback +]0;✦ Qoder CLI | Working   > Reply with hello.   Welcome to Qoder CLI ──────────────────────────────────────────────────────────────────────────────────────────────────── Sign in toget started, orexittheapplication.  ❯1.Signintocontinue  2. Exit the application + +↑/↓navigate·Enterselect·Escback +]0;✦ Reply with hello. | Working ]0;◇ Reply with hello. | Ready   x Qoder authenticationisnotready.Pleasesigninagain(e.g.via/login)beforesendinga  message.  Welcome to Qoder CLI ──────────────────────────────────────────────────────────────────────────────────────────────────── Sign in toget started, orexittheapplication.  ❯1.Signintocontinue  2. Exit the application + +↑/↓navigate·Enterselect·Escback +  + + + + + + + + + \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/qoder-ready.meta.json b/src/main/runtime/__fixtures__/qoder-ready.meta.json new file mode 100644 index 00000000000..d197b691db4 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-ready.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-28T08:15:00.339Z", + "platform": "darwin", + "command": ["qodercli", "--cwd", "/tmp/orca-qoder-probe/workspace"], + "cols": 100, + "rows": 32, + "note": "Qoder CLI 1.1.64; existing browser-login config; credit limit reached; no prompt submitted", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-ready.txt b/src/main/runtime/__fixtures__/qoder-ready.txt new file mode 100644 index 00000000000..e198f8ff3bb --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-ready.txt @@ -0,0 +1,66 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted──────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLIv1.1.64│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████SignedinBrowserLogin│4.Bespecificforthebestresults│ +╰─────────────────────────────────────────────────────────╯ + + +?forshortcuts +──────────────────────────────────────────────────────────────────────────────────────────────────── +Shift+TabtoAcceptEdits +──────────────────────────────────────────────────────────────────────────────────────────────────── +> Typeyourmessageor@path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────── +Model·ctx░░░░░░░░░░0%·/private/tmp/orca-qoder-probe/workspace +[>0q Qwen3.8-Max Model · ctx░░░░░░░░░░ 0% · /privte/tmp/oca-qoder-robe/workspace + ●Warning:Truecolor(24-bit)supportnotdetected.Usingaterminalwithtruecolor enabled will  result in a better visual experience.   ? for shortcuts ────────────────────────────────────────────────────────────────────────────────────────────────────  Shift+Tab to Accept Edits 1 MCP server ──────────────────────────────────────────────────────────────────────────────────────────────────── +> Typeyourmessageor@path/to/file +──────────────────────────────────────────────────────────────────────────────────────────────────── +Qwen3.8-MaxModel·ctx░░░░░░░░░░0%·/private/tmp/orca-qoder-probe/workspace + 1MCPserver · 34kills + + + + + Creditsexhausted.Use/usagefordetailsor/upgradefor more. + + + + + + + \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/qoder-trust-dialog.meta.json b/src/main/runtime/__fixtures__/qoder-trust-dialog.meta.json new file mode 100644 index 00000000000..f028a668af3 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-trust-dialog.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-09-28T07:56:42.619Z", + "platform": "darwin", + "command": [ + "qodercli", + "--config-dir", + "/tmp/orca-qoder-probe/config", + "--cwd", + "/tmp/orca-qoder-probe/workspace" + ], + "cols": 100, + "rows": 32, + "note": "Qoder CLI 1.1.64; isolated configuration; no account", + "exitCode": 129 +} diff --git a/src/main/runtime/__fixtures__/qoder-trust-dialog.txt b/src/main/runtime/__fixtures__/qoder-trust-dialog.txt new file mode 100644 index 00000000000..039f945f776 --- /dev/null +++ b/src/main/runtime/__fixtures__/qoder-trust-dialog.txt @@ -0,0 +1,58 @@ +[?u]11;?\[>q[>4;?m ]0;◇ Qoder CLI | Ready ]8;;78 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +[?25h[?25l[?1006l[?1002l[?1004h]0;◇ Qoder CLI | Ready [?2004h[?2004h[?1004h + ██████╭─Tipsforgettingstarted────────────────────────────────╮ +████│1.UseAGENTS.mdfilestocustomizeinteractions│ +██████QoderCLIv1.1.64│2./helpformoreinformation│ +████│3.Askcodingquestions,editcodeorruncommands│ + ██████NotLoginPleaseAuth│4.Bespecificforthebestresults│ +╰───────────────────────────────────────────────────────────╯ + + + Doyoutrustthefilesinthisfolder? +──────────────────────────────────────────────────────────────────────────────────────────────────── +Pleaseconfirmthisisyourownprojectorfromatrustedsource.Oncetrusted,QoderCLIcan +read,modify,andexecutefileshere,andwillloadlocalconfigurations(commands,hooks,MCP +servers,agents,skills,settings). + +/private/tmp/orca-qoder-probe/workspace + +❯1.Trustfolder +2.Don'ttrustandexit + +↑/↓navigate·Enterselect·Escback +[>0q  ● Warning: True color (24-bit) supportnotdetected.Usingaterminalwithtruecolorenabledwill  result in a better visual experience.   Do you trust the files in this folder? ──────────────────────────────────────────────────────────────────────────────────────────────────── Pleaseconfirmthisisyourownprojectorfromatrustedsource.Oncetrusted,QoderCLIcan read, modify, and execute files here, andwillloadlocalconfigurations(commands,hooks,MCP servers,agents,skills,settings).  /private/tmp/orca-qoder-probe/workspace  ❯ 1. Trust folder +2.Don'ttrustandexit + +↑/↓navigate·Enterselect·Escback + \ No newline at end of file diff --git a/src/main/runtime/agent-prompt-line-settle.test.ts b/src/main/runtime/agent-prompt-line-settle.test.ts index 45d8bca53a6..4ac0d9648ee 100644 --- a/src/main/runtime/agent-prompt-line-settle.test.ts +++ b/src/main/runtime/agent-prompt-line-settle.test.ts @@ -37,7 +37,7 @@ describe('agent prompt line-settle scheduling', () => { () => undefined, 'antigravity' ) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(submitDelayMs - 1) diff --git a/src/main/runtime/agent-prompt-receipt-correlation.test.ts b/src/main/runtime/agent-prompt-receipt-correlation.test.ts index e6b6d7f2793..77e8967b6d3 100644 --- a/src/main/runtime/agent-prompt-receipt-correlation.test.ts +++ b/src/main/runtime/agent-prompt-receipt-correlation.test.ts @@ -34,6 +34,7 @@ describe('agent prompt receipt correlation', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'historical-first', observationTimeoutMs: 0 @@ -41,6 +42,7 @@ describe('agent prompt receipt correlation', () => { await vi.runAllTimersAsync() const first = await firstPromise const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'historical-second', observationTimeoutMs: 0 diff --git a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts index c893623a18a..3d0c317a31b 100644 --- a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts @@ -102,7 +102,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { getForegroundProcess: async () => null }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -130,6 +132,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', acceptQueued: true, requestId: 'antigravity-pre-invocation', observationTimeoutMs: 20_000 @@ -156,7 +159,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { stateStartedAt: 1_000 }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -171,6 +176,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'codex') const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'hook-queued-first', observationTimeoutMs: 0 @@ -193,6 +199,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { getForegroundProcess: async () => null }) const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'hook-queued-second', observationTimeoutMs: 500 @@ -219,7 +226,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { it('does not write Enter after the PTY generation changes during settlement', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('terminal_handle_stale') await vi.advanceTimersByTimeAsync(0) @@ -256,6 +265,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -291,7 +301,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { sequenceAtSpawnStart ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -318,9 +330,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { sequenceAtSpawnStart ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -331,7 +343,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -351,8 +365,10 @@ describe('agent prompt submission runtime hook and generation cases', () => { } }) - const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt') - const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt') + const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { inputKind: 'driving' }) + const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await Promise.all([first, second]) @@ -373,6 +389,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-first', observationTimeoutMs: 0 @@ -380,6 +397,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { await vi.runAllTimersAsync() const first = await firstPromise const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-second', observationTimeoutMs: 0 @@ -418,6 +436,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { }) const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', { + inputKind: 'driving', beforeWrite: async () => { firstWriteReached() await firstGate @@ -430,7 +449,9 @@ describe('agent prompt submission runtime hook and generation cases', () => { 0 ) - const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt') + const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(replacement).resolves.toMatchObject({ accepted: true }) expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true) @@ -444,6 +465,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -466,6 +488,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') @@ -483,6 +506,7 @@ describe('agent prompt submission runtime hook and generation cases', () => { const controller = new AbortController() const { runtime, handle, writes } = await createPromptRuntime(() => undefined) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', signal: controller.signal }) const rejected = expect(submission).rejects.toThrow('request_aborted') diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 873863ada75..700445483a9 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -43,7 +43,9 @@ describe('agent prompt submission runtime', () => { } ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -59,7 +61,9 @@ describe('agent prompt submission runtime', () => { } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -75,7 +79,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -93,7 +99,9 @@ describe('agent prompt submission runtime', () => { } }) runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -109,7 +117,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -122,9 +132,9 @@ describe('agent prompt submission runtime', () => { const { runtime, handle, writes } = await createPromptRuntime(() => undefined) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -140,9 +150,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -155,9 +165,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -175,7 +185,9 @@ describe('agent prompt submission runtime', () => { ) runtime.onPtyData('pty-prompt', '}\x07\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -197,9 +209,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -214,7 +226,9 @@ describe('agent prompt submission runtime', () => { text: 'Permission required\r\nAllow once\r\nAllow always\r\nReject\r\n' }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -235,7 +249,9 @@ describe('agent prompt submission runtime', () => { } }) runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -257,7 +273,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', output, Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync() @@ -271,6 +289,7 @@ describe('agent prompt submission runtime', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -291,6 +310,7 @@ describe('agent prompt submission runtime', () => { let writeChecks = 0 const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + inputKind: 'driving', beforeWrite: () => { writeChecks += 1 if (writeChecks === 2) { @@ -319,9 +339,9 @@ describe('agent prompt submission runtime', () => { ) runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'review this', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') expect(writes).toEqual([]) }) @@ -369,7 +389,9 @@ describe('agent prompt submission runtime', () => { ) vi.setSystemTime(2_000) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -389,7 +411,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -408,7 +432,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -432,7 +458,9 @@ describe('agent prompt submission runtime', () => { Date.now() ) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -453,6 +481,7 @@ describe('agent prompt submission runtime', () => { ) const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', acceptQueued: true, requestId: 'queued-output-only', observationTimeoutMs: 0 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 f6298df8b17..3bc936c78a5 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 @@ -111,7 +111,9 @@ describe('agent prompt submit delay on a ConPTY host', () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime() const delayMs = submitDelayFor('review this', 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -131,7 +133,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { // Measured ConPTY ingest for 8 KB is 60-89 ms; the old constant charged 1_500 ms. const delayMs = submitDelayFor(prompt, 'win32') expect(delayMs).toBeLessThan(700) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs) @@ -146,7 +148,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createPromptRuntime() const prompt = 'y'.repeat(320_000) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') // Every byte is already handed to node-pty here -- the hazard is that the *host* is @@ -167,6 +169,7 @@ describe('agent prompt submit delay on a ConPTY host', () => { 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') @@ -186,7 +189,9 @@ describe('agent prompt submit delay on a ConPTY host', () => { const { runtime, handle, writes } = await createPromptRuntime() const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -215,7 +220,9 @@ describe('agent prompt submit delay follows the execution host', () => { patchPtyRecord(runtime, { isWsl: true, wslDistro: 'Ubuntu' }) const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') expect(delayMs).toBeGreaterThan(submitDelayFor(HOST_PROBE_PROMPT, 'linux')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -235,7 +242,9 @@ describe('agent prompt submit delay follows the execution host', () => { registerSshRemotePlatform('win32') const clientDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'darwin') const hostDelayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(clientDelayMs) @@ -255,7 +264,9 @@ describe('agent prompt submit delay follows the execution host', () => { registerSshRemotePlatform('linux') const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'linux') expect(delayMs).toBeLessThan(submitDelayFor(HOST_PROBE_PROMPT, 'win32')) - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -277,7 +288,9 @@ describe('agent prompt submit delay follows the execution host', () => { }) registerSshRemotePlatform(undefined) const delayMs = submitDelayFor(HOST_PROBE_PROMPT, 'win32') - const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT) + const submission = runtime.sendTerminalAgentPrompt(handle, HOST_PROBE_PROMPT, { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(delayMs - 1) @@ -341,7 +354,9 @@ describe('agent prompt render gate on a ConPTY host', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createSettlementRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000)) + const submission = runtime.sendTerminalAgentPrompt(handle, 'y'.repeat(320_000), { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') // Marker at 100 ms + a 1_500 ms quiet window would have submitted at ~1_600 ms, while @@ -370,7 +385,7 @@ describe('agent prompt render gate on a ConPTY host', () => { markerDelayMs: ingestMs - 1_000, noiseUntilMs: ingestMs + 20_000 }) - const submission = runtime.sendTerminalAgentPrompt(handle, prompt) + const submission = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.advanceTimersByTimeAsync(ingestMs + 8_000 - 1) @@ -387,7 +402,9 @@ describe('agent prompt render gate on a ConPTY host', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, submitTimes } = await createSettlementRuntime() - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const stalled = expect(submission).rejects.toThrow('agent_prompt_stalled') await vi.runAllTimersAsync() @@ -409,7 +426,11 @@ describe('plain terminal send suffix delay', () => { useHostPlatform('win32') vi.useFakeTimers() const { runtime, handle, writes, submitTimes } = await createPromptRuntime() - const send = runtime.sendTerminal(handle, { text: 'z'.repeat(320_000), enter: true }) + const send = runtime.sendTerminal( + handle, + { text: 'z'.repeat(320_000), enter: true }, + { inputKind: 'driving' } + ) // Same hazard as the agent-prompt path: a flat 500 ms wrote Enter mid-paste here. await vi.advanceTimersByTimeAsync(3_342) @@ -429,7 +450,7 @@ describe('plain terminal send suffix delay', () => { const send = runtime.sendTerminal( handle, { text: 'z'.repeat(320_000), enter: true }, - { signal: controller.signal } + { inputKind: 'driving', signal: controller.signal } ) const rejected = expect(send).rejects.toThrow('request_aborted') diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index f09e5d50fc5..297b9dd34e7 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionStoreState } from './agent-session-record-store-file' import { AgentSessionTabTable } from './agent-session-tab-table' import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command' @@ -10,7 +11,7 @@ export function commitConversationCommandRecord( ): void { const record = state.records.get(sessionId) if (!record || record.lease.runtimeFence !== fence) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) } state.records.set(sessionId, { ...record, conversationCommand: command }) if ( @@ -19,7 +20,7 @@ export function commitConversationCommandRecord( command.replacementSessionId ) { if (!state.records.has(command.replacementSessionId)) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } state.sessionTabs ??= new AgentSessionTabTable() state.sessionTabs.move(sessionId, command.replacementSessionId) diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 12c28345479..41e8d300e1e 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -6,6 +6,7 @@ * observed process identity, and a proved provider handle — in that order, at one fence. */ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { adjudicateAgentSessionRestart, evaluateAgentSessionAcquisition, @@ -41,10 +42,10 @@ export function withLease( export function assertFence(lease: AgentSessionLease, fence: number): void { if (lease.runtimeFence !== fence) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'leaseMoved' }) } if (lease.unreconciled) { - throw new Error('execution_owner_reconciling') + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'hostReconciling' }) } } @@ -66,7 +67,7 @@ export function reserveAgentSessionOwner(args: { probe: args.probe }) if (decision.decision === 'refused') { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.details) } if (decision.decision === 'retry-reservation') { return { record, disposition: 'retry-reservation' } @@ -106,11 +107,15 @@ export function commitAgentSessionProcessIdentity( const { record } = args assertFence(record.lease, args.fence) if (record.lease.claimStatus !== 'reserved' || record.lease.ownerProcess !== null) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } if (record.lease.reservedSpawnToken !== args.process.spawnToken) { // Why: a child that cannot echo the reserved token is not the process Orca started. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } return withLease(record, { ...record.lease, @@ -137,7 +142,9 @@ export function proveAgentSessionOwner(args: { record.lease.handoffStage !== 'new-owner-proving' || record.lease.ownerProcess === null ) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'spawnIdentityMismatch' + }) } if (args.link.handle.provider !== record.provider) { throw new Error('agent_session_provider_handle_provider_mismatch') diff --git a/src/main/runtime/agent-session-operation-admission.test.ts b/src/main/runtime/agent-session-operation-admission.test.ts index 0b8b9ff91f2..41df221ad12 100644 --- a/src/main/runtime/agent-session-operation-admission.test.ts +++ b/src/main/runtime/agent-session-operation-admission.test.ts @@ -48,6 +48,10 @@ describe('global agent-session operation admission', () => { fingerprint: 'different-send', now: NOW + 1 }).decision - ).toEqual({ decision: 'refused', code: 'agent_session_operation_conflict' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + }) }) }) diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts index f0408901868..57d2ecb172c 100644 --- a/src/main/runtime/agent-session-provider-handle-transition.ts +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { agentSessionProviderHandleChainHead, appendAgentSessionProviderHandleLink, @@ -20,7 +21,7 @@ export function recordAgentSessionProviderHandle(args: { throw new Error('agent_session_provider_handle_invalid') } if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } const providerHandleChain = appendAgentSessionProviderHandleLink( record.providerHandleChain, diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts index 8cf88d685f3..697b63a0422 100644 --- a/src/main/runtime/agent-session-record-options.ts +++ b/src/main/runtime/agent-session-record-options.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionOptionsReplacement, AgentSessionRecord @@ -7,8 +8,11 @@ export function replaceAgentSessionRecordOptions( record: AgentSessionRecord, replacement: AgentSessionOptionsReplacement ): AgentSessionRecord { - if (record.lease.runtimeFence !== replacement.fence || record.lease.claimStatus !== 'live') { - throw new Error('agent_session_ownership_unknown') + const { lease } = record + // At rest the host is the only writer: a pick is intent the next start replays. + const atRest = lease.claimStatus === 'released' && lease.ownerProcess === null + if (lease.runtimeFence !== replacement.fence || (lease.claimStatus !== 'live' && !atRest)) { + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } return { ...record, options: { ...replacement.options }, updatedAt: replacement.now } } 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 e0ecfec2835..56bcb6debb6 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 @@ -9,6 +9,7 @@ import { join } from 'node:path' 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 { AgentSessionReserveRequest } from './agent-session-reservation-admission' @@ -94,6 +95,34 @@ describe('chat tab table', () => { expect(store.getRecord('session-beta')).toBeNull() }) + it('refuses showing a tab with the situation typed, as every chat refusal is', async () => { + const store = await open() + await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + await store.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + + // The message stays the code: readers of a thrown refusal treat it as one. + await expect( + store.setSessionTabVisibility('session-beta', true, 'tab-alpha') + ).rejects.toSatisfy( + (error) => + isAgentSessionRefusalError(error) && + error.message === 'agent_session_conflict' && + error.refusal.details?.reason === 'tabIdTaken' + ) + await expect(store.setSessionTabVisibility('session-gone', true)).rejects.toSatisfy( + (error) => + isAgentSessionRefusalError(error) && + error.message === 'agent_session_identity_required' && + error.refusal.details?.reason === 'recordMissing' + ) + }) + it('frees a reserved id once its chat is hidden', async () => { const store = await open() await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index b2bf3632301..7df509dd810 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,3 +1,4 @@ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { commitConversationCommandRecord } from './agent-session-conversation-command-record' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' /** Durable single-writer session records and their operation ledger. */ @@ -326,11 +327,9 @@ export class AgentSessionRecordStore { return this.transact(() => { const record = this.state.records.get(sessionId) if (!record) { - throw new Error( - this.isSessionUnreadable(sessionId) - ? 'execution_owner_reconciling' - : 'agent_session_identity_required' - ) + throw this.isSessionUnreadable(sessionId) + ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + : agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } const next = apply(record) this.state.records.set(sessionId, next) diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index de8a452c87a..a5d1d899723 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -4,6 +4,10 @@ // — can be read on its own. Every value parsed here re-enters from a file this process did not // necessarily write, including one written by an older or newer build. +import { + readAgentSessionRefusalReference, + type AgentSessionAnyRefusalDetails +} from '../../shared/agent-session-wire-refusals' import { z } from 'zod' import { AGENT_SESSION_RESUME_FAILURE_OUTCOMES, @@ -28,6 +32,7 @@ const failureSchema = z.object({ failedAt: z.number().int().nonnegative(), outcome: z.enum(AGENT_SESSION_RESUME_FAILURE_OUTCOMES), reason: z.string().max(MAX_FAILURE_FIELD_LENGTH), + details: z.unknown().optional(), latestPrompt: z.string().max(MAX_FAILURE_FIELD_LENGTH), latestUserItemId: z.string().max(MAX_FAILURE_FIELD_LENGTH).nullable() }) @@ -47,7 +52,11 @@ export type AgentSessionResumeFailureRecord = { marker: AgentSessionResumeMarker failedAt: number outcome: AgentSessionResumeFailureOutcome + /** The refusal code, as it always was; the renderer's guidance keys on it. */ reason: string + /** The refusal's details beside the code; absent on older records and non-refusals. A record + * an unreleased build wrote with a `cause` instead reads as having none. */ + details?: AgentSessionAnyRefusalDetails /** The prompt the offer quoted, snapshotted because the session may no longer be readable. */ latestPrompt: string /** The chat's newest user message when this was filed, as the marker records it at teardown. A @@ -109,7 +118,16 @@ function parseFailures(value: unknown): AgentSessionResumeFailureRecord[] { return (Array.isArray(value) ? value : []).flatMap((failure: unknown) => { const parsed = failureSchema.safeParse(failure) const marker = parsed.success ? parseAgentSessionResumeMarker(parsed.data.marker) : null - return parsed.success && marker ? [{ ...parsed.data, marker }] : [] + if (!parsed.success || !marker) { + return [] + } + const { details: stored, ...rest } = parsed.data + // `reason` is the refusal code, so the details are read against it. + const details = readAgentSessionRefusalReference({ + code: rest.reason, + details: stored + })?.details + return [{ ...rest, marker, ...(details ? { details } : {}) }] }) } diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index 2150e5c7d51..d70fde57af3 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -342,6 +342,41 @@ describe('durable restart offers', () => { expect(JSON.parse(await readFile(filePath, 'utf8')).failed).toEqual([readable]) }) + it('keeps refusal details beside the code, read back against that code', async () => { + const readable = { + marker: marker(), + failedAt: NOW, + outcome: 'refused', + reason: 'agent_session_conflict', + latestPrompt: '', + latestUserItemId: null + } + await writeFile( + filePath, + JSON.stringify({ + version: 2, + entries: [], + failed: [ + { ...readable, details: { reason: 'claimConflicted', note: 'dropped' } }, + // An unreleased build wrote a cause here; it still parses, naming nothing. + { ...readable, marker: marker({ sessionId: 'older' }), cause: 'claimConflicted' }, + // A reason the code does not list is not this code's. + { + ...readable, + marker: marker({ sessionId: 'foreign' }), + details: { reason: 'promptGone' } + } + ] + }) + ) + + expect(await capsule.listFailed(NOW)).toEqual([ + { ...readable, details: { reason: 'claimConflicted' } }, + { ...readable, marker: marker({ sessionId: 'older' }) }, + { ...readable, marker: marker({ sessionId: 'foreign' }) } + ]) + }) + it('reads a malformed failure list as no failures', async () => { await writeFile( filePath, diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 8b54871b9f4..80ed0bb726e 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -201,15 +201,20 @@ export class AgentSessionRecoveryCapsule { /** Forgets the named sessions whatever their state. Unlike `clearAll`, this is not a fence: a * later teardown of the same chat may record a fresh offer. */ - dismiss(sessionIds: readonly string[], now: number): Promise { + dismiss( + sessionIds: readonly string[], + now: number, + /** A record this answers true for stays: read against the stored marker, under the lock. */ + keep: (marker: AgentSessionResumeMarker) => boolean = () => false + ): Promise { return withFileTransactionLock(this.filePath, async () => { const named = new Set(sessionIds) const state = await this.readState() const { entries, failed } = normalizeState(state, now) const dismissed = new Set( [...entries, ...failed] + .filter((record) => named.has(record.marker.sessionId) && !keep(record.marker)) .map((record) => record.marker.sessionId) - .filter((sessionId) => named.has(sessionId)) ) if (dismissed.size > 0) { await this.publish( diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 9b74186ffbe..d86383ddae7 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -9,6 +9,7 @@ * inside a transaction, which is what makes the record and its operation row land together. */ +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { agentSessionOperationKey, evaluateAgentSessionOperation, @@ -112,7 +113,7 @@ export function requireAgentSessionRecordForReplay( if (!record) { // Why: the recorded effect is no longer reconstructable, and re-running it would be a second // spawn rather than a replay. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'recordMissing' }) } return record } @@ -128,11 +129,13 @@ export function admitPendingAgentSessionReservationReplay( probe: request.probe }) if (decision.decision === 'refused') { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.details) } if (decision.decision !== 'retry-reservation') { // A replay may continue only its still-present reservation. - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'replaySuperseded' + }) } return record } @@ -170,10 +173,10 @@ export function applyAgentSessionReservation( const existing = state.records.get(request.sessionId) if (!existing) { if (state.unreadableRecords.has(request.sessionId)) { - throw new Error('execution_owner_reconciling') + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) } if (request.expectedFence !== null) { - throw new Error('agent_session_checkpoint_stale') + throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' }) } assertReservedTabUnheld(state, request) return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } @@ -185,7 +188,7 @@ export function applyAgentSessionReservation( existing.accountHome.path !== request.accountHome.path ) { // Why: location, provider, and account are the session identity; changing one is a fork. - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'identityMismatch' }) } // A create may take over only a record that never bound a conversation and whose last // attempt is proven gone: that is the same as creating it fresh, under a fresh provider id. @@ -194,7 +197,7 @@ export function applyAgentSessionReservation( !request.adoptedHandleLink && agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' if (request.expectedFence === null && !recreatable) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) } assertReservedTabUnheld(state, request) const pinned = { @@ -240,7 +243,9 @@ function assertAdoptedConversationUnowned( (link) => agentSessionProviderHandleRoot(link.handle) === root ) if (holdsSameConversation) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) } } } @@ -258,11 +263,13 @@ function assertReservedTabUnheld( return } if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { - throw new Error('agent_session_operation_invalid') + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) } const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) if (holder !== undefined && holder !== request.sessionId) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) } } @@ -324,7 +331,7 @@ export function commitAgentSessionReservation( if (decision.decision === 'refused') { // An aged-out row proves nothing more: a released reservation runs no effect. if (decision.code !== 'agent_session_operation_expired' || !continued) { - throw new Error(decision.code) + throw agentSessionRefusalError(decision.code, decision.details) } const row = pendingAgentSessionOperationRow({ ...request.operation, now: request.now }) return reserveWithOperationRow(state, continued, row, leaseTtlMs) diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts index 5c57c18ba41..7da1e7633ef 100644 --- a/src/main/runtime/agent-session-surface-release-transition.ts +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -8,6 +8,7 @@ // The fence still moves, so the next owner is a new generation: an attach or settlement still // holding the stopped owner's fence is refused as stale rather than acting on its successor. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import { assertFence, withLease } from './agent-session-lease-transitions' @@ -37,7 +38,7 @@ export function releaseAgentSessionOwnerAfterSurfaceClose(args: { const { record } = args assertFence(record.lease, args.expectedFence) if (!isSurfaceReleasableAgentSessionRecord(record)) { - throw new Error('agent_session_ownership_unknown') + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'leaseMoved' }) } return withLease(record, { ...record.lease, diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 3855d431989..6022aac8bf2 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -1,4 +1,5 @@ import { isAgentSessionId, type AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import type { AgentSessionStoreState } from './agent-session-record-store-file' @@ -92,7 +93,7 @@ export class AgentSessionTabTable { private put(tabId: string, sessionId: string): void { const owner = this.sessionByTab.get(tabId) if (owner !== undefined && owner !== sessionId) { - throw new Error('agent_session_conflict') + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) } this.hide(sessionId) this.sessionByTab.set(tabId, sessionId) @@ -120,7 +121,7 @@ export function setAgentSessionTabVisibility( tabId?: string ): void { if (visible && !state.records.has(sessionId)) { - throw new Error('agent_session_identity_required') + throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } state.sessionTabs ??= new AgentSessionTabTable() if (visible) { diff --git a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts index a9a3b4484a8..eb4acb14d5e 100644 --- a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts +++ b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts @@ -226,7 +226,7 @@ export class ClaudeAgentTeamsTmuxDispatcher { const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane) const text = tmuxSendKeysText(parsed.positional, parsed.flags.has('-l')) if (text) { - await api.sendTerminal(pane.handle, { text }) + await api.sendTerminal(pane.handle, { text }, { inputKind: 'driving' }) } return '' } diff --git a/src/main/runtime/claude-agent-teams-types.ts b/src/main/runtime/claude-agent-teams-types.ts index 10234f5226d..bddfad17d17 100644 --- a/src/main/runtime/claude-agent-teams-types.ts +++ b/src/main/runtime/claude-agent-teams-types.ts @@ -6,6 +6,7 @@ import type { RuntimeTerminalShow, RuntimeTerminalSplit } from '../../shared/runtime-types' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type AgentTeamsTmuxCompatRequest = { teamId: string @@ -43,7 +44,8 @@ export type AgentTeamsTerminalApi = { readTerminal(handle: string, opts?: { limit?: number }): Promise sendTerminal( handle: string, - action: { text?: string; enter?: boolean; interrupt?: boolean } + action: { text?: string; enter?: boolean; interrupt?: boolean }, + options: { inputKind: TerminalInputKind } ): Promise focusTerminal(handle: string): Promise closeTerminal(handle: string): Promise diff --git a/src/main/runtime/claude-structured-exit-mid-response.test.ts b/src/main/runtime/claude-structured-exit-mid-response.test.ts new file mode 100644 index 00000000000..fd30df7dad0 --- /dev/null +++ b/src/main/runtime/claude-structured-exit-mid-response.test.ts @@ -0,0 +1,72 @@ +// A Claude CLI that exits on its own after its start landed, with a message handed to it: the chat +// says Claude stopped, by name. Against the production runtime, adapter, record store and host, +// with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +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 { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-exit-mid-response' +const CALLER = { callerKey: 'client-1' } + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + vi.restoreAllMocks() + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +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: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) +} + +describe('a started Claude CLI that exits while a response is in progress', () => { + it('says Claude stopped, and that the conversation can continue', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + await send(host, 'hello') + await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + + claude.child(SESSION).exit(scriptedClaudeExitError('claude stream-json exited (code 137)')) + await waitForStructuredAgentSessionRecovery() + + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.failure + ? [{ text: item.body.text, kind: item.body.failure.kind }] + : [] + ) + ).toEqual([ + { + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + kind: 'providerExited' + } + ]) + ) + }) +}) diff --git a/src/main/runtime/claude-structured-failed-start-resume.test.ts b/src/main/runtime/claude-structured-failed-start-resume.test.ts index 32362cbafdc..cd4f44d59f8 100644 --- a/src/main/runtime/claude-structured-failed-start-resume.test.ts +++ b/src/main/runtime/claude-structured-failed-start-resume.test.ts @@ -55,7 +55,7 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { // Before the spawn returns, the start time of a dead pid is unreadable; during that read, the // child is found closed afterwards. Both must answer with what the CLI said. it.each(['spawn', 'start-time-read'] as const)( - "refuses the create with the CLI's own diagnostic when it exits at %s", + "refuses the create in a sentence, not the CLI's diagnostic, when it exits at %s", async (at) => { const diagnostic = 'claude stream-json exited (code 1): claude: not signed in' claude.behave(SESSION, { exitsDuringSpawn: { diagnostic, at } }) @@ -65,7 +65,10 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { expect(created).toMatchObject({ ok: false, - refusal: { message: expect.stringContaining('not signed in'), ownerVerdict: 'exited' } + refusal: { + message: 'Claude stopped before it finished starting. Send your message to try again.', + ownerVerdict: 'exited' + } }) } ) diff --git a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts index b64f6980689..3404bcbf283 100644 --- a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts +++ b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts @@ -76,7 +76,7 @@ export function fakeClaude(providerSession: string) { connection.calls.push({ subtype: 'interrupt', params: {} }) return undefined }, - cancelAsyncMessage: async () => {}, + cancelAsyncMessage: async () => false, stopTask: async (taskId) => { connection.calls.push({ subtype: 'stop_task', params: { taskId } }) }, diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 8fcef7e4a48..7a0c66e1b85 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -7,11 +7,15 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-resumed-start' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' let claude = createScriptedClaudeRuntime([SESSION]) @@ -38,11 +42,11 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { await waitForStructuredAgentSessionRecovery() await host.close(SESSION) - // The user reopens it; this time the CLI never answers, then dies, and its tree is unprovable. + // The user reopens it and sends; this time the CLI never answers, then dies, and its tree is + // unprovable. Opening starts nothing: the send does. claude.behave(SESSION, { initHangs: true, closeUnproven: true }) - await host.hold(SESSION, 'surface-1') const events: AgentSessionSubscribeEvent[] = [] - host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(event) }) + await host.subscribe({ id: 'sub-1', sessionId: SESSION, emit: (event) => events.push(event) }) const body = hostTestMessage('hello') const fence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const sent = await host.send(CALLER, { @@ -59,21 +63,22 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { body }) expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + // Accepted first; the delivery loop starts the second child after. + await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(2)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() - await vi.waitFor(() => - expect(statusTexts(events)).toContainEqual( - expect.stringMatching(/stopped before it finished starting: .*not signed in/) - ) - ) + await vi.waitFor(() => expect(statusTexts(events)).toContainEqual(STARTUP_TEXT)) // Never written, so it did not happen: refused, not left in doubt. - const submission = host - .journalSnapshot(SESSION) - .submissions.find( - (entry) => entry.clientMessageId === (sent.ok && sent.value.clientMessageId) - ) - expect(submission).toMatchObject({ dispatchState: 'rejected' }) + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === (sent.ok && sent.value.clientMessageId) + ) + // The stderr the exit carried is beside the sentence, as a log detail, and never in it. + expect(submission).toMatchObject({ + dispatchState: 'rejected', + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed', detail: { text: DIAGNOSTIC, audience: 'log' } } + }) }) }) diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index da1135f588f..efb8d3cde16 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -10,11 +10,15 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio 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 { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-send-held' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' let claude = createScriptedClaudeRuntime([SESSION]) let operations = 0 @@ -52,22 +56,22 @@ function fence(host: StructuredAgentSessionHost): number { return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } -function statusRows(host: StructuredAgentSessionHost): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) +async function statusRows(host: StructuredAgentSessionHost): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) } -function submission(host: StructuredAgentSessionHost, clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } /** The CLI keeps dying at startup: the latest child exits with the diagnostic once it exists. */ async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { await vi.waitFor(() => expect(claude.children(SESSION)).toHaveLength(count)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -82,7 +86,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { ok: true }) await failLatestStart(host, 1) - expect(statusRows(host)).toEqual([expect.stringContaining('not signed in')]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) const releasedFence = fence(host) // The delivery loop asks for the child back and the message waits for its start; the CLI @@ -95,17 +99,15 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { await failLatestStart(host, 2) // Rejected with the cause, not left in doubt; one row for this attempt names it. - await vi.waitFor(() => - expect(submission(host, held)).toMatchObject({ + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(statusRows(host)).toEqual([ - expect.stringContaining('not signed in'), - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) // The restart moved the fence twice: its acquisition, and the exit that released it. expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) @@ -120,7 +122,7 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) - expect(statusRows(host)).toHaveLength(2) + expect(await statusRows(host)).toHaveLength(2) }) }) @@ -138,7 +140,7 @@ describe('a send while the first Claude start is still answering initialize', () await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.children(SESSION)).toHaveLength(1) - expect(statusRows(host)).toEqual([]) + expect(await statusRows(host)).toEqual([]) }) it('is rejected with the diagnostic when the CLI dies first, and restarts nothing', async () => { @@ -150,16 +152,15 @@ describe('a send while the first Claude start is still answering initialize', () const held = await send(host, 'hello') await failLatestStart(host, 1) - await vi.waitFor(() => - expect(submission(host, held)).toMatchObject({ + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(statusRows(host)).toEqual([ - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT]) expect(fence(host)).toBe(startedFence + 1) expect(claude.children(SESSION)).toHaveLength(1) expect(claude.child(SESSION).calls).not.toContain('send') 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 817b00144bc..183879abe1a 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 @@ -11,14 +11,18 @@ import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire 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 { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' -import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' const SESSION = 'claude-send-restart-dies-first' const CALLER = { callerKey: 'client-1' } const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STARTUP_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void): Promise { +function eventually(assertion: () => unknown): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } @@ -77,21 +81,21 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise (item.body.kind === 'status' ? [item.body.text] : [])) +async function statusRows(host: StructuredAgentSessionHost): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) } -function submission(host: StructuredAgentSessionHost, clientMessageId: string) { - return host - .journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { await eventually(() => expect(claude.children(SESSION)).toHaveLength(count)) - claude.child(SESSION).exit(new Error(DIAGNOSTIC)) + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() await eventually(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -139,17 +143,15 @@ describe('a send whose restarted Claude child dies before it proves its start', await failLatestStart(host, 2) // Provably not delivered, with the cause; not "unconfirmed". - await eventually(() => - expect(submission(host, sent)).toMatchObject({ + await eventually(async () => + expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', // Worded for the user: the red line under the composer shows it as it stands. - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) - expect(statusRows(host)).toEqual([ - expect.stringContaining('not signed in'), - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) expect(fence(host)).toBe(releasedFence + 2) expect(claude.children(SESSION)).toHaveLength(2) expect(claude.child(SESSION).calls).not.toContain('send') @@ -160,7 +162,7 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(() => expect(claude.children(SESSION)).toHaveLength(3)) await eventually(() => expect(claude.child(SESSION).calls).toContain('send')) expect(claude.child(SESSION).calls.filter((call) => call === 'send')).toHaveLength(1) - expect(statusRows(host)).toHaveLength(2) + expect(await statusRows(host)).toHaveLength(2) }) // A restart refused because its child died before it was handed over leaves one row, from the @@ -177,18 +179,16 @@ describe('a send whose restarted Claude child dies before it proves its start', claude.behave(SESSION, { exitsDuringSpawn: { diagnostic: DIAGNOSTIC, at } }) const sent = await send(host, 'hello?') - await eventually(() => - expect(submission(host, sent)).toMatchObject({ + await eventually(async () => + expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', - reason: `The provider stopped before it finished starting: ${DIAGNOSTIC}.` + reason: STARTUP_TEXT, + rejection: { kind: 'providerStartFailed' } }) ) await waitForStructuredAgentSessionRecovery() - expect(statusRows(host)).toEqual([ - `The provider stopped before it finished starting: ${DIAGNOSTIC}.`, - `The provider stopped before it finished starting: ${DIAGNOSTIC}.` - ]) + expect(await statusRows(host)).toEqual([STARTUP_TEXT, STARTUP_TEXT]) } ) @@ -199,7 +199,7 @@ describe('a send whose restarted Claude child dies before it proves its start', ok: true }) const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => { @@ -214,9 +214,7 @@ describe('a send whose restarted Claude child dies before it proves its start', await eventually(() => { const seen = received(events) expect(seen.submissions.get(sent)).toBe('rejected') - expect(seen.statusTexts).toContainEqual( - expect.stringMatching(/stopped before it finished starting: .*not signed in \(rig\)/) - ) + expect(seen.statusTexts).toContainEqual(STARTUP_TEXT) // The subscriber ended up on the fence the exit published, not the one the restart did. expect(seen.fences.at(-1)).toBe(fence(host)) }) @@ -227,8 +225,8 @@ describe('a send whose restarted Claude child dies before it proves its start', }) describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber open throughout', () => { - const STARTUP_FAILURE = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in (rig).' + // The stderr rides beside the sentence as a log detail; the sentence is the same every time. + const STARTUP_FAILURE = STARTUP_TEXT /** Status rows a subscriber has been shown, one per row whatever frame carried it. */ function shownRows(events: AgentSessionSubscribeEvent[]): Map { @@ -254,7 +252,7 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => { @@ -268,8 +266,8 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o // Send: accepted, and its delivery restarts the child, which dies before starting. const sent = await send(host, 'hello?') await failLatestStart(host, 2) - await eventually(() => - expect(submission(host, sent)).toMatchObject({ + await eventually(async () => + expect(await submission(host, sent)).toMatchObject({ dispatchState: 'rejected', reason: STARTUP_FAILURE }) @@ -287,8 +285,8 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o } }) const retried = await send(host, 'hello?') - await eventually(() => - expect(submission(host, retried)).toMatchObject({ + await eventually(async () => + expect(await submission(host, retried)).toMatchObject({ dispatchState: 'rejected', reason: STARTUP_FAILURE }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 186f4e0f30d..82db5d6c542 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -364,9 +364,28 @@ describe('a structured Claude session over agentSession.*', () => { claudeAuthPolicy = { stripAuthEnv: true } // The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path // refuses at spawn-env.ts:25 rather than letting it beat the pinned account. - const refused = await call('agentSession.create', createIntentParams()) + const params = createIntentParams() + const sentence = + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' + const refused = await call('agentSession.create', params) - expect(JSON.stringify(refused)).toContain('explicit Anthropic auth environment') + // The thrown answer keeps its wire code; only its words are the ones its replay reads. + expect(refused).toMatchObject({ + ok: false, + error: { code: 'runtime_error', message: sentence } + }) + // Its replay reads the same sentence, beside the situation it names. + expect(await call('agentSession.create', params)).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'managedAccountEnvOverride' }, + message: sentence + } + } + }) // Refused before spawn: no provider child was ever opened. expect(claude.connections).toHaveLength(0) }) @@ -379,11 +398,11 @@ describe('a structured Claude session over agentSession.*', () => { await waitForStructuredAgentSessionRecovery() const guidance = itemsOf(await subscribe()).find((item) => item.body?.kind === 'status') + // The adapter typed the refusal, so the row names the situation rather than quoting Orca. expect(guidance?.body).toMatchObject({ kind: 'status', - text: expect.stringMatching( - /stopped before it finished starting: .*not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s - ) + text: 'Claude is not signed in for the selected account. Sign in, then send your message again.', + failure: { kind: 'notSignedIn' } }) expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) // A failed start is not auto-resumed into the same failure. @@ -393,7 +412,7 @@ describe('a structured Claude session over agentSession.*', () => { // The root's death is first-hand. Its descendants were never snapshottable, or one was seen // alive; either way the lease follows the root, so the reservation goes with it. it.each(['unverifiable', 'live'] as const)( - 'releases a session whose CLI self-exited during create with its tree %s, with its diagnostic intact', + 'releases a session whose CLI self-exited during create with its tree %s, refused in a sentence', async (tree) => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', @@ -409,7 +428,8 @@ describe('a structured Claude session over agentSession.*', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: expect.stringContaining('claude: not signed in'), + // The CLI's stderr is log text; the person reads what the chat's start failure says. + message: 'Claude stopped before it finished starting. Send your message to try again.', ownerVerdict: 'exited' } } @@ -444,7 +464,7 @@ describe('a structured Claude session over agentSession.*', () => { ok: false, refusal: { code: 'agent_session_operation_invalid', - message: expect.stringContaining('claude: not signed in'), + message: 'Claude stopped before it finished starting. Send your message to try again.', ownerVerdict: 'exited' } } @@ -473,7 +493,7 @@ describe('a structured Claude session over agentSession.*', () => { }) it.each(['unverifiable', 'live'] as const)( - 'reopens a chat whose stop saw the Claude root exit with its tree %s', + 'restarts a chat whose stop saw the Claude root exit with its tree %s', async (tree) => { await ok('agentSession.create', createIntentParams()) const first = claude.live() @@ -483,14 +503,19 @@ describe('a structured Claude session over agentSession.*', () => { return false } const host = getStructuredAgentSessionHost() - // The idle release clock's eviction: the lease follows the root, so the host lets go. + // The lease follows the root, so the host lets go. await host?.close(SESSION) expect(host?.hasSession(SESSION)).toBe(false) - // What the chat surface's `agentSession.hold` does when the user comes back to it. - await host?.hold(SESSION, 'desktop-chat:reopen') + // The user comes back and sends: that send is what starts Claude again. + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'back' }] } + await ok('agentSession.send', { + envelope: envelope('agentSession.send', { body }, leaseOf(SESSION).runtimeFence), + body + }) - expect(claude.connections).toHaveLength(2) + await vi.waitFor(() => expect(claude.connections).toHaveLength(2)) + await vi.waitFor(() => expect(claude.live().sent).toHaveLength(1)) expect(host?.hasSession(SESSION)).toBe(true) } ) @@ -510,11 +535,9 @@ describe('a structured Claude session over agentSession.*', () => { // A descendant seen alive is one that survived the close ladder, such as an MCP server that // ignores SIGTERM; it no longer holds the chat. it.each(['unverifiable', 'live'] as const)( - 'restarts an open chat after a Claude crash whose tree was %s', + 'restarts a chat on its next send after a Claude crash whose tree was %s', async (tree) => { const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) - // The open chat surface is what asks the host to bring Claude back. - await getStructuredAgentSessionHost()?.hold(SESSION, 'desktop-chat:open') const connection = claude.live() connection.exitVerdict = { root: 'exited', tree } connection.close = async () => { @@ -537,19 +560,19 @@ describe('a structured Claude session over agentSession.*', () => { dispatchState: 'unknown', reason: 'provider_exited_before_acknowledgement' }) - // Held back, sends failed with the crash until the idle clock stopped the chat. + // The crash releases the lease; nothing restarts Claude until the chat has work for it. await waitForStructuredAgentSessionRecovery() - expect(claude.connections).toHaveLength(2) - expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) - const lease = leaseOf(SESSION) - expect(lease).toMatchObject({ claimStatus: 'live', handoffStage: null }) + expect(claude.connections).toHaveLength(1) + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) const next = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] } - const sent = await ok<{ submission: { dispatchState: string } }>('agentSession.send', { - envelope: envelope('agentSession.send', { body: next }, lease.runtimeFence), + await ok('agentSession.send', { + envelope: envelope('agentSession.send', { body: next }, leaseOf(SESSION).runtimeFence), body: next }) - expect(sent.submission.dispatchState).toBe('accepted') - expect(claude.live().sent).toHaveLength(1) + await vi.waitFor(() => expect(claude.connections).toHaveLength(2)) + expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) + await vi.waitFor(() => expect(claude.live().sent).toHaveLength(1)) + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'live', handoffStage: null }) } ) diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts new file mode 100644 index 00000000000..863cbaeaebf --- /dev/null +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -0,0 +1,139 @@ +// A Claude start can fail on Orca's side while the CLI is still running: a saved option it can't +// restore, or an init frame naming another session. Orca ends that child itself, so the chat must +// not say Claude stopped on its own; only an exit Orca saw says that. Against the production +// runtime, adapter, record store and host, with only the CLI process scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +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 { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' +import { + createScriptedClaudeRuntime, + scriptedClaudeExitError +} from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-startup-fault' +const CALLER = { callerKey: 'client-1' } +const DIAGNOSTIC = 'claude stream-json exited (code 1): claude: not signed in (rig)' +const STOPPED_TEXT = 'Claude stopped before it finished starting. Send your message to try again.' + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + vi.restoreAllMocks() + 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, + value: { submission: { dispatchState: 'pending' } } + }) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function failureRows(host: StructuredAgentSessionHost) { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' && item.body.failure + ? [{ text: item.body.text, kind: item.body.failure.kind }] + : [] + ) +} + +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +async function released(host: StructuredAgentSessionHost): Promise { + await waitForStructuredAgentSessionRecovery() + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) +} + +describe('a Claude start that Orca fails while the CLI is still running', () => { + it('reads as a start that could not happen when a saved option cannot be restored', async () => { + claude.behave(SESSION, { optionWritesFail: true }) + const host = await claude.install() + await expect( + host.attach( + CALLER, + claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } }) + ) + ).resolves.toMatchObject({ ok: true }) + await released(host) + + expect(claude.child(SESSION).calls).toContain('set_permission_mode') + expect(await failureRows(host)).toEqual([ + { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } + ]) + }) + + it('rejects a held message as a start that could not happen when init names another session', async () => { + claude.behave(SESSION, { initHangs: true, initNamesForeignSession: true }) + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const held = await send(host, 'hello') + + claude.child(SESSION).answerInit() + await released(host) + + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringMatching(/^Claude couldn't start\./), + rejection: { kind: 'startFailed' } + }) + ) + expect(await failureRows(host)).toEqual([ + { text: expect.stringMatching(/^Claude couldn't start\./), kind: 'startFailed' } + ]) + expect(claude.child(SESSION).calls).not.toContain('send') + }) + + it('still says Claude stopped when the CLI exits on its own before its start lands', async () => { + claude.behave(SESSION, { initHangs: true }) + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const held = await send(host, 'hello') + + claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) + await released(host) + + await vi.waitFor(async () => + expect(await submission(host, held)).toMatchObject({ + dispatchState: 'rejected', + reason: STOPPED_TEXT, + rejection: { kind: 'providerStartFailed' } + }) + ) + expect(await failureRows(host)).toEqual([{ text: STOPPED_TEXT, kind: 'providerStartFailed' }]) + }) +}) diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 38d0de314d2..ce26647ae85 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -72,10 +72,10 @@ function turnReportsModel(model: string): void { }) } -function statusRows(host: StructuredAgentSessionHost): string[] { - return host - .journalSnapshot(SESSION) - .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) +async function statusRows(host: StructuredAgentSessionHost): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) } async function send(host: StructuredAgentSessionHost, text: string): Promise { @@ -119,7 +119,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' timeout: DEADLINE_MS * 40 }) expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual([]) - expect(statusRows(host)).toEqual([]) + expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) // The proven child takes the next message. @@ -245,7 +245,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' timeout: DEADLINE_MS * 40 }) expect(record(host)?.lease.claimStatus).toBe('live') - expect(statusRows(host)).toEqual([]) + expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) }) }) diff --git a/src/main/runtime/dsh-readiness-transcript.test.ts b/src/main/runtime/dsh-readiness-transcript.test.ts new file mode 100644 index 00000000000..36608b987b0 --- /dev/null +++ b/src/main/runtime/dsh-readiness-transcript.test.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { + getAgentLabel, + isGeminiTerminalTitle, + resolveExplicitTerminalTitleAgentType +} from '../../shared/terminal-title-agent-type' + +// The committed capture of a real `dsh-tui` 0.10.2 launch on @deepseek-ai/dsh 0.1.5-rc.1. +// Every rule below is written against these bytes rather than a remembered screen; see +// docs/reference/agent-pty-transcript-capture.md. +const TRANSCRIPT = readFileSync(join(__dirname, '__fixtures__', 'dsh-tui-ready-no-key.txt'), 'utf8') + +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) + +/** The OSC 0 title DSH-TUI sets, read back out of the capture. */ +function readFirstOscTitle(data: string): string { + // Indexed scan rather than a regex: the delimiters are control characters. + const open = data.indexOf(`${ESC}]0;`) + const bodyStart = open === -1 ? -1 : open + 4 + const terminator = bodyStart === -1 ? -1 : data.indexOf(BEL, bodyStart) + if (terminator === -1) { + throw new Error('the captured transcript carries no BEL-terminated OSC 0 title') + } + return data.slice(bodyStart, terminator) +} + +describe('DSH-TUI readiness from captured terminal bytes', () => { + it('fires the composer-ready signal well before the transcript ends', () => { + const scanner = createDraftPasteReadyScanner('dsh-composer-prompt') + let readyAt = -1 + // Feed it in PTY-sized chunks so a marker split across chunk boundaries is exercised. + for (let offset = 0; offset < TRANSCRIPT.length; offset += 1024) { + const chunk = TRANSCRIPT.slice(offset, offset + 1024) + if (scanner.observe(chunk).ready && readyAt === -1) { + readyAt = offset + chunk.length + } + } + // The composer glyph lands at byte 5910 of ~70KB: readiness must not wait out the + // whale intro that keeps painting behind it (the grok failure mode this signal fixes). + expect(readyAt).toBeGreaterThan(0) + expect(readyAt).toBeLessThan(8192) + }) + + it('arms the quiet-window fallback from DECSET 2004 as well', () => { + const scanner = createDraftPasteReadyScanner('dsh-composer-prompt') + expect(scanner.observe(TRANSCRIPT.slice(0, 40)).armQuietTimer).toBe(true) + }) + + it('identifies the pane from DSH’s own title, not Gemini’s', () => { + const title = readFirstOscTitle(TRANSCRIPT) + // DSH-TUI's idle prefix is `✦`, which is Gemini CLI's WORKING glyph. + expect(title).toContain('✦') + expect(title).toContain('\u{1F40B}') + expect(isGeminiTerminalTitle(title)).toBe(false) + expect(getAgentLabel(title)).toBe('DeepSeek Harness') + expect(resolveExplicitTerminalTitleAgentType(title)).toBe('dsh') + }) + + it('keeps a working DSH title out of Claude’s braille-spinner lane', () => { + // `titlePrefix` cycles through `⠂`/`⠐` while a turn runs (Chat.js + // TITLE_SPINNER_FRAMES), both of which are in the braille block Claude claims. + for (const frame of ['⠂', '⠐']) { + const working = `${frame} \u{1F40B} fix the flaky test` + expect(getAgentLabel(working)).toBe('DeepSeek Harness') + } + }) +}) diff --git a/src/main/runtime/freebuff-status-transcript.test.ts b/src/main/runtime/freebuff-status-transcript.test.ts new file mode 100644 index 00000000000..04aa1e0a2ab --- /dev/null +++ b/src/main/runtime/freebuff-status-transcript.test.ts @@ -0,0 +1,146 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const transcript = readFileSync( + join(import.meta.dirname, '__fixtures__/freebuff-lifecycle.txt'), + 'utf8' +) + +describe('Freebuff execution-host status', () => { + it('publishes real running, question, and settled screens into the canonical store', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + const states: string[] = [] + const questions: string[] = [] + try { + // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. + for (const frame of transcript.split(/(?<=\x1b\[\?2026l)/)) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, frame, Date.now()) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + const row = wiring.statusStore.getStatusSnapshot()[0] + if (row && states.at(-1) !== row.state) { + states.push(row.state) + } + if (row?.interactivePrompt) { + questions.push(row.interactivePrompt) + } + } + expect(states).toContain('working') + expect(states).toContain('waiting') + expect(states.at(-1)).toBe('done') + expect(questions.join('\n')).toMatch(/blue|green/i) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + it.each([ + { name: 'login', cols: 100, rows: 32, input: 'Sign in to Freebuff' }, + { name: 'trust', cols: 120, rows: 40, input: 'Trust repository agent files? [y/N]' } + ])('publishes captured $name startup as blocked', async ({ name, cols, rows, input }) => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols, rows } + }, + wiring.deps + ) + try { + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), + Date.now() + ) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ + state: 'blocked', + toolInput: input, + agentType: 'freebuff' + }) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + + it('clears startup blocks and starts a fresh session after an earlier completed turn', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + launchAgent: 'freebuff', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + try { + for (const name of ['trust', 'ready', 'lifecycle', 'ready']) { + runtime.onPtyData( + TRANSCRIPT_PANE_PTY_ID, + readFileSync(join(import.meta.dirname, `__fixtures__/freebuff-${name}.txt`), 'utf8'), + Date.now() + ) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()[0]?.state).toBe( + name === 'trust' ? 'blocked' : 'done' + ) + if (name === 'ready') { + expect(wiring.statusStore.getStatusSnapshot()[0]).toMatchObject({ + sessionBoundary: true, + prompt: '' + }) + } + if (name === 'lifecycle') { + expect(wiring.statusStore.getStatusSnapshot()[0]?.sessionBoundary).not.toBe(true) + } + } + expect(wiring.statusStore.getStatusSnapshot()[0]?.toolInput).toBeUndefined() + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) + + it('does not infer remote status from the client screen', async () => { + const wiring = makeAgentStatusStoreWiring() + const { runtime } = await createTranscriptPane( + { + data: '', + paneTitle: 'Freebuff', + foregroundProcess: 'freebuff', + connectionId: 'ssh-test', + size: { cols: 120, rows: 40 } + }, + wiring.deps + ) + try { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, transcript, Date.now()) + await runtime.serializeMainTerminalBuffer(TRANSCRIPT_PANE_PTY_ID) + expect(wiring.statusStore.getStatusSnapshot()).toEqual([]) + } finally { + runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0) + } + }) +}) diff --git a/src/main/runtime/freebuff-terminal-status.ts b/src/main/runtime/freebuff-terminal-status.ts new file mode 100644 index 00000000000..09ffee3d50d --- /dev/null +++ b/src/main/runtime/freebuff-terminal-status.ts @@ -0,0 +1,25 @@ +import { FreebuffScreenStatusTracker } from '../../shared/freebuff-screen-status' +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import type { HeadlessEmulator } from '../daemon/headless-emulator' + +const trackers = new WeakMap() + +export function observeFreebuffTerminalStatus( + emulator: HeadlessEmulator, + data: string, + startupCommand: string | undefined, + launchAgent?: string | null +) { + let tracker = trackers.get(emulator) + if (!tracker) { + const identified = + launchAgent === 'freebuff' || + recognizeAgentProcessFromCommandLine(startupCommand)?.agent === 'freebuff' + tracker = new FreebuffScreenStatusTracker(identified) + trackers.set(emulator, tracker) + } + return tracker.observe(data, () => ({ + lines: emulator.getVisibleLines(), + alternate: emulator.isAlternateScreen + })) +} diff --git a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts index 5dd2908413b..3bc5b570704 100644 --- a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts @@ -4,10 +4,7 @@ import { spawnSurfaceClaimSequence, SURFACE_CLAIM_WITHOUT_STANDING } from './pty-recorded-surface-topology' -import { - observeStructuredWorker, - resolveStructuredWorkerAuthority -} from './structured-worker-authority' +import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import type { RuntimeLeafRecord } from './runtime-terminal-state-records' import { OrcaRuntimeWithSubscribeToTerminalResize } from './orca-runtime-subscribe-to-terminal-resize' import type { @@ -210,12 +207,10 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim this.getOrchestrationDbIfAvailable?.() ?? null ) if (structured) { - // `resolveBareOrchestrationRecipient` routes direct mail through this, not through - // getTerminalPaneKey. The connected-gate below exists so mail is never routed to a corpse, - // so the structured answer needs a real liveness proof too, not just a registry hit. - return observeStructuredWorker(structured.identity).status === 'live' - ? structured.identity.paneKey - : null + // `resolveBareOrchestrationRecipient` routes direct mail through this. A structured worker is + // a recipient while this runtime owns it, running or at rest: mail to one at rest is a send, + // and the send starts its agent. + return structured.identity.paneKey } const runtimePty = this.getLivePtyForHandle(handle) if (runtimePty) { diff --git a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts index f433c75793d..7cb440b4bac 100644 --- a/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts +++ b/src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts @@ -182,6 +182,7 @@ export class OrcaRuntimeWithAttachRemoteTerminalSourceRangeConsumer extends Orca try { await assertTerminalInputWithinLimitWithYield(data) await this.writeTerminalInputChunks(ptyId, data, { + inputKind: 'driving', // Why: a phone can claim the floor while a paste yields between chunks. beforeWrite: () => { if (this.getDriver(ptyId).kind === 'mobile') { 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 e2e1e2337fc..73d0e44499f 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 @@ -12,7 +12,9 @@ import type { Tab } from '../../shared/tab-types' import { resolveTerminalCloseTarget, terminalSurfaceCloseMutation, - type PaneCloseResolution + type PaneCloseResolution, + type RendererTerminalClose, + type TerminalSurfaceCloseOptions } from './terminal-surface-close' import type { TerminalPaneCloseTarget, @@ -113,8 +115,7 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu protected async closeTerminalSurface( worktreeId: string, target: TerminalSurfaceCloseTarget, - // closedByLayoutOwner goes away with D1, once main owns the terminal layout. - options: { allowMissing?: boolean; force?: boolean; closedByLayoutOwner?: boolean } = {} + options: TerminalSurfaceCloseOptions = {} ): Promise { const store = this.store if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.runDurableMutation) { @@ -153,8 +154,8 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu } /** The desktop renderer's close intent: it already guarded, removed and killed; this only reports. */ - async closeTerminalSurfaceFromRenderer(worktreeId: string, target: TerminalSurfaceCloseTarget) { - const options = { allowMissing: true, force: true, closedByLayoutOwner: true } + async closeTerminalSurfaceFromRenderer({ worktreeId, target, reason }: RendererTerminalClose) { + const options = { allowMissing: true, force: true, closedByLayoutOwner: true, reason } await this.closeTerminalSurface(worktreeId, target, options) } diff --git a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts index 7239f66d316..5ef34314e42 100644 --- a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts +++ b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts @@ -11,6 +11,7 @@ import { buildHeadlessMobileSessionTabGroups } from './mobile-session-layout-pro import { appendRetiredTerminalSurfaceProofs } from './mobile-session-terminal-retirement-proof' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' +import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-session-contracts' export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWithCloseStructuredAgentSessionTab { protected async closeHeadlessMobileTerminalTab( @@ -22,6 +23,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi killPtys?: boolean authorizedPty?: RuntimePtyWorktreeRecord force?: boolean + reason?: RuntimeSessionTabCloseReason } = {} ): Promise { const closedParentTabId = tab.parentTabId @@ -42,7 +44,8 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi { kind: 'tab', tabId: closedParentTabId }, { allowMissing: options.allowMissingPersistedTab, - force: options.force + force: options.force, + reason: options.reason } ) if (!acknowledgeRetirement().matches) { diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 7f161f81dc5..3f7047c6487 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -172,6 +172,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU await this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, { allowMissingPersistedTab: Boolean(ptyCloseAuthority), force: options.force, + reason: options.reason, killPtys: options.localPtyTeardownOwnedExternally !== true && (options.reason === undefined || options.reason === 'user'), diff --git a/src/main/runtime/orca-runtime-cold-restore-terminal-membership.test.ts b/src/main/runtime/orca-runtime-cold-restore-terminal-membership.test.ts new file mode 100644 index 00000000000..321f4d40ae6 --- /dev/null +++ b/src/main/runtime/orca-runtime-cold-restore-terminal-membership.test.ts @@ -0,0 +1,577 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { OrcaRuntimeService } from './orca-runtime' +import { setRuntimeDesktopSurface } from './runtime-desktop-surface' +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' + +const WORKTREE_ID = 'repo::/worktree' +const REPO_ID = 'repo' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' +const SPLIT_ROOT = { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: LEFT }, + second: { type: 'leaf' as const, leafId: RIGHT } +} + +type RestoreHost = { + repo: { + id: string + path: string + displayName: string + badgeColor: string + addedAt: number + connectionId?: string + executionHostId?: ExecutionHostId + } + hostId: ExecutionHostId + connectionId: string | null + ptyIds: { left: string; right: string } + /** An SSH relaunch has not bound its PTY into the saved session yet. */ + persistsPtyBindings: boolean +} + +const LOCAL_HOST: RestoreHost = { + repo: { id: REPO_ID, path: '/worktree', displayName: 'repo', badgeColor: 'blue', addedAt: 1 }, + hostId: LOCAL_EXECUTION_HOST_ID, + connectionId: null, + ptyIds: { left: 'pty-left', right: 'pty-right' }, + persistsPtyBindings: true +} +const SSH_HOST: RestoreHost = { + repo: { ...LOCAL_HOST.repo, connectionId: 'ssh-1' }, + hostId: 'ssh:ssh-1', + connectionId: 'ssh-1', + ptyIds: { left: 'ssh:ssh-1@@pty-left', right: 'ssh:ssh-1@@pty-right' }, + persistsPtyBindings: false +} +const RUNTIME_HOST: RestoreHost = { + ...LOCAL_HOST, + repo: { ...LOCAL_HOST.repo, executionHostId: 'runtime:env-1' }, + hostId: 'runtime:env-1' +} + +// A cold restore: the saved split survives, and an earlier incarnation change left the repo's +// terminal membership host-authoritative, but no PTY has registered yet. +function makeColdRestoredSession(host: RestoreHost): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'tab', + ptyId: host.persistsPtyBindings ? host.ptyIds.left : null, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + tab: { + root: SPLIT_ROOT, + activeLeafId: LEFT, + expandedLeafId: null, + ptyIdsByLeafId: host.persistsPtyBindings + ? { [LEFT]: host.ptyIds.left, [RIGHT]: host.ptyIds.right } + : {} + } + }, + terminalTopologyRevisionByRepoId: { [REPO_ID]: 2 } + } +} + +function makeRendererFrame( + host: RestoreHost, + options: { + version?: number + leaves?: readonly string[] + extraTabs?: readonly RuntimeMobileSessionSnapshotTab[] + } = {} +): RuntimeMobileSessionTabsSnapshot { + const leaves = options.leaves ?? [LEFT, RIGHT] + const ptyIdByLeaf: Record = { + [LEFT]: host.ptyIds.left, + [RIGHT]: host.ptyIds.right + } + const parentLayout = { + root: leaves.length === 2 ? SPLIT_ROOT : { type: 'leaf' as const, leafId: leaves[0] }, + activeLeafId: leaves[0], + expandedLeafId: leaves[0], + ptyIdsByLeafId: Object.fromEntries(leaves.map((leafId) => [leafId, ptyIdByLeaf[leafId]])) + } + const extraTabs = options.extraTabs ?? [] + return { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: options.version ?? 1, + activeGroupId: 'group', + activeTabId: `tab::${leaves[0]}`, + activeTabType: 'terminal', + tabGroups: [ + { id: 'group', activeTabId: 'tab', tabOrder: ['tab', ...extraTabs.map((tab) => tab.id)] } + ], + tabs: [ + ...leaves.map((leafId, index) => ({ + type: 'terminal' as const, + id: `tab::${leafId}`, + parentTabId: 'tab', + leafId, + ptyId: ptyIdByLeaf[leafId], + title: leafId === LEFT ? 'Left' : 'Right', + parentLayout, + isActive: index === 0 + })), + ...extraTabs + ] + } +} + +const RENDERER_TABS = [ + { + tabId: 'tab', + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEFT, + layout: SPLIT_ROOT + } +] + +function publishRendererFrame( + runtime: OrcaRuntimeService, + frame: RuntimeMobileSessionTabsSnapshot +): ReturnType { + return runtime.syncWindowGraph(1, { tabs: RENDERER_TABS, leaves: [], mobileSessionTabs: [frame] }) +} + +function coldRestoredRuntime(host: RestoreHost = LOCAL_HOST): { + runtime: OrcaRuntimeService + sessions: Map +} { + const sessions = new Map([ + [host.hostId, makeColdRestoredSession(host)] + ]) + // The desktop window is live, so main must not rebuild the list from the saved session. + const liveWindow = { + isDestroyed: () => false, + webContents: { isDestroyed: () => false, send: () => {} } + } + setRuntimeDesktopSurface({ + showNotification: () => false, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only liveness and send off its authoritative window on these paths. + findWindowById: () => liveWindow as never, + onIpc: () => {}, + removeIpcListener: () => {} + }) + const runtime = new OrcaRuntimeService( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list, fence, exit and close paths read only repos and the workspace session; the rest of Store is unreached. + withDurableRuntimeStore({ + getRepos: () => [host.repo], + getRepo: (id: string) => (id === REPO_ID ? host.repo : undefined), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), + getProjects: () => [], + getWorkspaceSessionHostIds: () => [...sessions.keys()], + getWorkspaceSession: (hostId?: ExecutionHostId) => + sessions.get(hostId ?? LOCAL_EXECUTION_HOST_ID) ?? getDefaultWorkspaceSession(), + setWorkspaceSession: (next: WorkspaceSessionState, hostId?: ExecutionHostId) => { + sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, next) + }, + flushPendingOrThrowAsync: async () => {}, + // The production store commits asynchronously; a synchronous flush on these paths is a bug. + flushOrThrow: () => { + throw new Error('synchronous flush') + } + }) as never + ) + runtime.attachWindow(1) + return { runtime, sessions } +} + +function registerLeaf(runtime: OrcaRuntimeService, host: RestoreHost, leaf: 'left' | 'right') { + runtime.registerPty(host.ptyIds[leaf], WORKTREE_ID, host.connectionId, { + tabId: 'tab', + leafId: leaf === 'left' ? LEFT : RIGHT, + incarnationId: `incarnation-${leaf}` + }) +} + +function surfaces(result: RuntimeMobileSessionTabsResult | undefined): string[] { + return (result?.tabs ?? []).map((tab) => `${tab.id}:${'status' in tab ? tab.status : ''}`) +} + +async function listedSurfaces(runtime: OrcaRuntimeService): Promise { + return surfaces(await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)) +} + +// The session-tabs notify coalescer's max wait (mobile-session-tabs-notify-coalescer.ts). +const COALESCED_PUSH_MS = 250 + +describe('a restored terminal published before its PTY registers', () => { + afterEach(() => { + setRuntimeDesktopSurface(null) + vi.useRealTimers() + }) + + it.each([ + ['local', LOCAL_HOST], + ['SSH without a saved PTY binding', SSH_HOST], + ['runtime host', RUNTIME_HOST] + ])('%s: is listed pending, then pushed ready when its PTY registers', async (_label, host) => { + vi.useFakeTimers() + const { runtime } = coldRestoredRuntime(host) + publishRendererFrame(runtime, makeRendererFrame(host)) + expect(await listedSurfaces(runtime)).toEqual([ + `tab::${LEFT}:pending-handle`, + `tab::${RIGHT}:pending-handle` + ]) + vi.advanceTimersByTime(COALESCED_PUSH_MS) + const published: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event)) + + registerLeaf(runtime, host, 'left') + vi.advanceTimersByTime(COALESCED_PUSH_MS) + + // Registration alone reaches clients: no renderer frame or graph change follows it here. + expect(surfaces(published.at(-1))).toEqual([ + `tab::${LEFT}:ready`, + `tab::${RIGHT}:pending-handle` + ]) + const pushes = published.length + expect( + runtime.syncWindowGraph(1, { + tabs: RENDERER_TABS, + leaves: [], + mobileSessionTabs: [], + unchangedMobileSessionWorktrees: [WORKTREE_ID] + }).mobileSessionResyncWorktrees + ).toBeUndefined() + publishRendererFrame(runtime, makeRendererFrame(host)) + vi.advanceTimersByTime(COALESCED_PUSH_MS) + expect(published).toHaveLength(pushes) + expect(await listedSurfaces(runtime)).toEqual([ + `tab::${LEFT}:ready`, + `tab::${RIGHT}:pending-handle` + ]) + + registerLeaf(runtime, host, 'right') + vi.advanceTimersByTime(COALESCED_PUSH_MS) + + expect(surfaces(published.at(-1))).toEqual([`tab::${LEFT}:ready`, `tab::${RIGHT}:ready`]) + unsubscribe() + }) + + it('pushes a restore of several panes once, not once per registering pane', () => { + vi.useFakeTimers() + const { runtime } = coldRestoredRuntime() + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST)) + vi.advanceTimersByTime(COALESCED_PUSH_MS) + const published: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((event) => published.push(event)) + + registerLeaf(runtime, LOCAL_HOST, 'left') + registerLeaf(runtime, LOCAL_HOST, 'right') + vi.advanceTimersByTime(COALESCED_PUSH_MS) + + expect(published.map(surfaces)).toEqual([[`tab::${LEFT}:ready`, `tab::${RIGHT}:ready`]]) + unsubscribe() + }) + + it('keeps listing a restored pane whose PTY never returns as pending', async () => { + const { runtime } = coldRestoredRuntime() + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST)) + + registerLeaf(runtime, LOCAL_HOST, 'left') + runtime.registerPty('pty-other', WORKTREE_ID, null, { + tabId: 'tab-other', + leafId: RIGHT, + incarnationId: 'incarnation-other' + }) + + expect(await listedSurfaces(runtime)).toEqual([ + `tab::${LEFT}:ready`, + `tab::${RIGHT}:pending-handle` + ]) + }) +}) + +describe('a retired restored terminal stays out of a later renderer frame', () => { + afterEach(() => setRuntimeDesktopSurface(null)) + + it('after its process exits and the retirement is saved', async () => { + const { runtime, sessions } = coldRestoredRuntime() + registerLeaf(runtime, LOCAL_HOST, 'left') + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST)) + expect(await listedSurfaces(runtime)).toContain(`tab::${LEFT}:ready`) + + await runtime.onPtyExit('pty-left', 0, 'incarnation-left') + + expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalLayoutsByTabId.tab?.root).toEqual({ + type: 'leaf', + leafId: RIGHT + }) + // A lagging renderer still lists the exited pane, at a newer version. + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST, { version: 2 })) + expect(await listedSurfaces(runtime)).not.toContain(`tab::${LEFT}:ready`) + expect(await listedSurfaces(runtime)).not.toContain(`tab::${LEFT}:pending-handle`) + }) + + it('after its last pane exits from a runtime-host partition an older copy still lists', async () => { + const { runtime, sessions } = coldRestoredRuntime(RUNTIME_HOST) + // Left behind in another partition before the catalog owner rotated. + sessions.set(LOCAL_EXECUTION_HOST_ID, makeColdRestoredSession(RUNTIME_HOST)) + registerLeaf(runtime, RUNTIME_HOST, 'left') + registerLeaf(runtime, RUNTIME_HOST, 'right') + publishRendererFrame(runtime, makeRendererFrame(RUNTIME_HOST)) + + await runtime.onPtyExit('pty-left', 0, 'incarnation-left') + await runtime.onPtyExit('pty-right', 0, 'incarnation-right') + // Emptying the owner's partition re-routes reads to the older copy. + expect(sessions.get(RUNTIME_HOST.hostId)?.tabsByWorktree[WORKTREE_ID]).toEqual([]) + + publishRendererFrame(runtime, makeRendererFrame(RUNTIME_HOST, { version: 2 })) + expect(await listedSurfaces(runtime)).toEqual([]) + }) + + it('after the user closes it on the desktop', async () => { + const { runtime, sessions } = coldRestoredRuntime() + registerLeaf(runtime, LOCAL_HOST, 'left') + registerLeaf(runtime, LOCAL_HOST, 'right') + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST)) + expect(await listedSurfaces(runtime)).toEqual([`tab::${LEFT}:ready`, `tab::${RIGHT}:ready`]) + + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST, { version: 2, leaves: [LEFT] })) + await runtime.onPtyExit('pty-right', 0, 'incarnation-right') + expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalLayoutsByTabId.tab?.root).toEqual({ + type: 'leaf', + leafId: LEFT + }) + + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST, { version: 3 })) + expect(await listedSurfaces(runtime)).toEqual([`tab::${LEFT}:ready`]) + }) +}) + +// An SSH worktree: the phone closes a tab while a relaunched sibling tab has not registered yet. +const SSH_PTY_X = 'ssh:ssh-1@@pty-x' +const SSH_PTY_Y = 'ssh:ssh-1@@pty-y' + +function makeSshSession(tabIds: readonly ('tab-x' | 'tab-y')[]): WorkspaceSessionState { + // tab-x's relaunched PTY has not bound yet, so only tab-y saves a relay binding. + const specs = { + 'tab-x': { leafId: LEFT, ptyId: null }, + 'tab-y': { leafId: RIGHT, ptyId: SSH_PTY_Y } + } + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [WORKTREE_ID]: tabIds.map((id, index) => ({ + id, + ptyId: specs[id].ptyId, + worktreeId: WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder: index, + createdAt: index + 1 + })) + }, + terminalLayoutsByTabId: Object.fromEntries( + tabIds.map((id) => [ + id, + { + root: { type: 'leaf' as const, leafId: specs[id].leafId }, + activeLeafId: specs[id].leafId, + expandedLeafId: null, + ptyIdsByLeafId: specs[id].ptyId ? { [specs[id].leafId]: specs[id].ptyId } : {} + } + ]) + ), + terminalTopologyRevisionByRepoId: { [REPO_ID]: 2 } + } +} + +function publishSshRendererFrame(runtime: OrcaRuntimeService): void { + const tabs = [ + { tabId: 'tab-x', leafId: LEFT, ptyId: SSH_PTY_X }, + { tabId: 'tab-y', leafId: RIGHT, ptyId: SSH_PTY_Y } + ] + runtime.syncWindowGraph(1, { + tabs: tabs.map(({ tabId, leafId }) => ({ + tabId, + worktreeId: WORKTREE_ID, + title: tabId, + activeLeafId: leafId, + layout: { type: 'leaf' as const, leafId } + })), + leaves: [], + mobileSessionTabs: [ + { + worktree: WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: 'group', + activeTabId: `tab-y::${RIGHT}`, + activeTabType: 'terminal', + tabGroups: [{ id: 'group', activeTabId: 'tab-y', tabOrder: ['tab-x', 'tab-y'] }], + tabs: tabs.map(({ tabId, leafId, ptyId }) => ({ + type: 'terminal' as const, + id: `${tabId}::${leafId}`, + parentTabId: tabId, + leafId, + ptyId, + title: tabId, + isActive: tabId === 'tab-y' + })) + } + ] + }) +} + +describe('a surface the host retired stays retired when a restored sibling registers', () => { + afterEach(() => setRuntimeDesktopSurface(null)) + + it('keeps a phone-closed SSH terminal closed while its remote PTY is still exiting', async () => { + const { runtime, sessions } = coldRestoredRuntime(SSH_HOST) + sessions.set(SSH_HOST.hostId, makeSshSession(['tab-x', 'tab-y'])) + const kill = vi.fn(() => true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close path uses only these two relays. + runtime.setNotifier({ + closeTerminal: vi.fn(), + // The renderer durably retires the tab and acks; its pruned frame is still in flight. + closeTerminalTab: vi.fn(async () => { + sessions.set(SSH_HOST.hostId, makeSshSession(['tab-x'])) + }) + } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close path kills and inventories only; the remote kill lands asynchronously. + runtime.setPtyController({ + write: () => true, + kill, + listProcesses: vi.fn(async () => + [SSH_PTY_X, SSH_PTY_Y].map((id) => ({ id, cwd: '/worktree', title: 'shell' })) + ), + getForegroundProcess: async () => null + } as never) + publishSshRendererFrame(runtime) + runtime.registerPty(SSH_PTY_Y, WORKTREE_ID, 'ssh-1', { + tabId: 'tab-y', + leafId: RIGHT, + incarnationId: 'incarnation-y' + }) + expect(await listedSurfaces(runtime)).toContain(`tab-y::${RIGHT}:ready`) + + await runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, 'tab-y', { reason: 'user' }) + expect(kill).toHaveBeenCalledWith(SSH_PTY_Y) + runtime.registerPty(SSH_PTY_X, WORKTREE_ID, 'ssh-1', { + tabId: 'tab-x', + leafId: LEFT, + incarnationId: 'incarnation-x' + }) + + expect(await listedSurfaces(runtime)).not.toContain(`tab-y::${RIGHT}:ready`) + }) + + it('keeps a phone-closed chat tab closed', async () => { + const { runtime } = coldRestoredRuntime() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the chat close path uses only this relay. + runtime.setNotifier({ closeSessionTab: vi.fn(async () => {}) } as never) + publishRendererFrame( + runtime, + makeRendererFrame(LOCAL_HOST, { + extraTabs: [ + { + type: 'agent-session', + id: 'chat', + title: 'Chat', + sessionId: 'session-chat', + agent: 'claude', + isActive: false + } + ] + }) + ) + expect(await listedSurfaces(runtime)).toContain('chat:') + + await runtime.closeMobileSessionTab(`id:${WORKTREE_ID}`, 'chat', { reason: 'user' }) + registerLeaf(runtime, LOCAL_HOST, 'left') + + expect(await listedSurfaces(runtime)).not.toContain('chat:') + }) +}) + +// A phone creates a terminal while the desktop's frame still lacks it: the spawn registered and the +// host published the tab itself, as it does for a create the desktop has not published yet. +const PHONE_LEAF = '33333333-3333-4333-8333-333333333333' + +async function runtimeWithUnpublishedPhoneCreate(): Promise { + const { runtime } = coldRestoredRuntime() + const handlers = new Map void>() + const webContents = { + isDestroyed: () => false, + setBackgroundThrottling: () => {}, + send: (channel: string, payload: { requestId: string }) => { + if (channel !== 'terminal:requestTabCreate') { + return + } + runtime.registerPty('pty-phone', WORKTREE_ID, null, { + tabId: 'tab-phone', + leafId: PHONE_LEAF, + incarnationId: 'incarnation-phone' + }) + handlers.get('terminal:tabCreateReply')?.( + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-phone', title: 'Terminal' } + ) + } + } + setRuntimeDesktopSurface({ + showNotification: () => false, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create path reads only liveness and these webContents members off its authoritative window. + findWindowById: () => ({ isDestroyed: () => false, webContents }) as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the tab-create reply channel is registered on this path. + onIpc: (channel, listener) => handlers.set(channel, listener as never), + removeIpcListener: (channel) => handlers.delete(channel) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create path focuses only when activating, which this create does not. + runtime.setNotifier({ focusTerminal: vi.fn() } as never) + Object.assign(runtime, { + resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/worktree', + connectionId: null, + repo: LOCAL_HOST.repo, + folderWorkspace: null + })) + }) + publishRendererFrame(runtime, makeRendererFrame(LOCAL_HOST)) + await runtime.createMobileSessionTerminal(`id:${WORKTREE_ID}`, { + activate: false, + clientNavigationId: 'phone' + }) + return runtime +} + +describe('a surface the host added stays listed when a restored sibling registers', () => { + afterEach(() => setRuntimeDesktopSurface(null)) + + it('keeps a phone-created terminal listed', async () => { + const runtime = await runtimeWithUnpublishedPhoneCreate() + expect(await listedSurfaces(runtime)).toContain(`tab-phone::${PHONE_LEAF}:ready`) + + registerLeaf(runtime, LOCAL_HOST, 'left') + + expect(await listedSurfaces(runtime)).toContain(`tab-phone::${PHONE_LEAF}:ready`) + }) +}) diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 391b55ac5b0..3c9c8b7eab9 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-termi import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core' import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts' import type { RuntimeTerminalSend } from '../../shared/runtime-types' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimeAgentPromptWriteOptions } from './runtime-terminal-contracts' import { assertTerminalInputWithinLimitWithYield, @@ -103,7 +104,8 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string - } = {} + inputKind: TerminalInputKind + } ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { @@ -152,7 +154,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso async sendTerminalAgentPrompt( handle: string, prompt: string, - options: RuntimeAgentPromptWriteOptions = {} + options: RuntimeAgentPromptWriteOptions ): Promise { // Why the consuming agent: the foreground process reads the bytes; launchAgent covers startup. const payloadFor = (ptyId: string): string => { diff --git a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts index b9f35478505..4ebeed55f90 100644 --- a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts +++ b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts @@ -6,6 +6,7 @@ import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-que import { isNativeWindowsConptyPty } from './terminal-model-query-authority' import { getTerminalViewAttributes } from './terminal-view-attribute-store' import { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' +import { PROCESS_BOUNDARY_GROUND } from '../../shared/terminal-mode-reset-profiles' export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer { /** Shared factory for the per-PTY runtime emulators (seed, hydration, and @@ -45,7 +46,7 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi // pending and flushes at the ready marker or the 15s // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query // reply is delayed at most that bound, not lost. - this.ptyController?.write(ptyId, reply) + this.ptyController?.write(ptyId, reply, 'query-reply') } } }) @@ -195,4 +196,21 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi state.writeChain = state.writeChain.then(() => state.emulator.clearScrollback()) await state.writeChain } + + // Public: Reset Terminal must ground this model too; park/reveal and mobile restore from it. + async resetHeadlessTerminalInputModes(ptyId: string): Promise { + // Why now, not on the chain: onPtyData scans live bytes into these on arrival. + // Focus is outside their model, so the plain ground is exact. + this.scanProviderModeTrackers(ptyId, PROCESS_BOUNDARY_GROUND) + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + // Why on the chain: the ground must land after every PTY chunk already queued. + const completion = state.writeChain.then(async () => { + await state.emulator.write(state.ownership.groundInputModes()) + }) + state.writeChain = completion.catch(() => {}) + await completion + } } diff --git a/src/main/runtime/orca-runtime-deliver-pending-messages.ts b/src/main/runtime/orca-runtime-deliver-pending-messages.ts index 55c6a8dad5e..2cd1cc69c00 100644 --- a/src/main/runtime/orca-runtime-deliver-pending-messages.ts +++ b/src/main/runtime/orca-runtime-deliver-pending-messages.ts @@ -137,7 +137,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv let settlesInEnterCallback = false try { const payload = formatMessagePointer(unread.length, mailboxHandle) - const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false + const wrote = this.ptyController?.write(deliveryPtyId, payload, 'driving') ?? false if (!wrote) { return } @@ -171,7 +171,7 @@ export class OrcaRuntimeWithDeliverPendingMessages extends OrcaRuntimeWithResolv if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) { return } - this.ptyController?.write(deliveryPtyId, '\r') + this.ptyController?.write(deliveryPtyId, '\r', 'driving') } catch { // Terminal may have closed during the delay; mail remains queued for check. } finally { 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 d61374c3466..5eb0bffb8e8 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -22,6 +22,7 @@ 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' +import { matchesProcessIncarnation } from './orchestration/worker-terminal-process-liveness' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { /** Every pane key this PTY could be addressed by, including restored receipts. */ @@ -270,4 +271,38 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim : undefined }) } + /** + * Recover a live terminal handle for a worker whose durable handle stopped resolving + * (renderer graph epoch bump / handle invalidation) while its PTY is still tracked. Fences on + * the recorded process incarnation EXACTLY — never a bare ptyId, worktree, or pane — so a + * reused ptyId belonging to a different process can never be closed, and fails closed on an + * unknown host scope (this is also consumed by workerShow, which does no lease re-check). + * Returns a freshly minted live handle, or null when no live PTY carries that exact incarnation. + */ + resolveTerminalHandleByProcessIncarnation( + processIncarnation: string, + serializedHostScope: string | null + ): string | null { + if (!processIncarnation || !serializedHostScope) { + return null + } + // Scan by the incarnation itself (startsWith + exact equality, mirroring + // classifyWorkerTerminalProcessIncarnation) rather than splitting on a colon, so relay/SSH + // ptyIds and colon-bearing incarnationIds still match. A pty with no incarnationId can never + // match, so the legacy `${runtimeId}:${ptyId}:${ptyGeneration}` fence stays fail-closed. + for (const [ptyId, pty] of this.ptysById) { + if (!matchesProcessIncarnation(ptyId, pty.incarnationId, processIncarnation)) { + continue + } + const hostScope = this.getOrchestrationCompatibilityHostScope(pty) + if (!hostScope || JSON.stringify(hostScope) !== serializedHostScope) { + // Keep scanning: a colon-ambiguous decoy pty in a different host scope that this + // incarnation string happens to prefix-match must not suppress the genuine same-scope + // pty later in ptysById. The scope check still fences the real match below. + continue + } + return this.issuePtyHandle(pty) + } + return null + } } diff --git a/src/main/runtime/orca-runtime-get-runtime-id.ts b/src/main/runtime/orca-runtime-get-runtime-id.ts index a56825a4a85..ee7fe60e85e 100644 --- a/src/main/runtime/orca-runtime-get-runtime-id.ts +++ b/src/main/runtime/orca-runtime-get-runtime-id.ts @@ -180,6 +180,10 @@ export class OrcaRuntimeWithGetRuntimeId extends OrcaRuntimeWithHasExactPersiste return this.workspaceSessions.get(worktreeId) } + protected getOwnWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null { + return this.workspaceSessions.getOwnPartition(worktreeId) + } + protected setWorkspaceSessionForWorktree( worktreeId: string, session: WorkspaceSessionState 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 f2143b9772c..ce2cf31b9ce 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 @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support' @@ -156,7 +157,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR ): Promise { const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) if (!support.supported) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } const settings = this.requireStore().getSettings() const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index cf7e8e56db1..107cbd63407 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -19,6 +19,7 @@ import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { buildWorktreeListingPage } from './worktree-listing-host-scope' +import { structuredWorkerOwesWork } from './structured-worker-custody' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' @@ -171,7 +172,11 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis firstWorkRenameDeps(this.requireStore(), this) ) }, - ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}) + ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}), + // Read per sweep tick from the orchestration database: a worker whose dispatch is open keeps + // its agent running. No database answers no. + hasOpenDispatch: (record) => + structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record) }) } } diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index d9d6b2acfb9..7f42304eb8b 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -36,7 +36,7 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt this.ptyController!.hasChildProcesses?.(ptyId) ?? Promise.resolve(false), subscribeToData: (ptyId, listener) => this.subscribeToTerminalData(ptyId, listener), readRecentOutput: (ptyId) => this.recentPtyOutputById.get(ptyId)?.read(), - write: (ptyId, data) => this.ptyController?.write(ptyId, data) + write: (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) } } diff --git a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts index d0425cdc1f9..7aea8a0d0d7 100644 --- a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts +++ b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts @@ -5,6 +5,8 @@ import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' import { makePaneKey } from '../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { layoutContainsLeafId } from '../persistence/restoring-sessions/terminal-layout-normalization' import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement' import type { @@ -27,6 +29,35 @@ import type { } from '../../shared/artifacts' export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithAutomationOperations { + /** The host's saved session, when it still lists `tabId` under this worktree. */ + protected getPersistedSessionListingTerminalTab( + worktreeId: string, + tabId: string, + session = this.getWorkspaceSessionForWorktree(worktreeId) + ): WorkspaceSessionState | null { + const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, worktreeId) : null + return session && + sessionWorktreeId && + session.tabsByWorktree[sessionWorktreeId]?.some((candidate) => candidate.id === tabId) + ? session + : null + } + + protected hasPersistedTerminalSurfaceMembership( + worktreeId: string, + tabId: string, + leafId: string + ): boolean { + // Why own partition: emptying a rotated runtime partition re-routes reads to an older copy + // that can still list a surface retirement just removed. + const layout = this.getPersistedSessionListingTerminalTab( + worktreeId, + tabId, + this.getOwnWorkspaceSessionForWorktree(worktreeId) + )?.terminalLayoutsByTabId[tabId] + return Boolean(layout && layoutContainsLeafId(layout.root, leafId)) + } + protected hasExactPersistedTerminalSurfaceIdentity(expected: { worktreeId: string tabId: string @@ -34,19 +65,10 @@ export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends Orc ptyId: string incarnationId: string }): boolean { - const session = this.getWorkspaceSessionForWorktree(expected.worktreeId) - const sessionWorktreeId = session - ? resolveTerminalSessionWorktreeId(session, expected.worktreeId) - : null - if (!session || !sessionWorktreeId) { - return false - } - const tab = session.tabsByWorktree[sessionWorktreeId]?.find( - (candidate) => candidate.id === expected.tabId - ) + const session = this.getPersistedSessionListingTerminalTab(expected.worktreeId, expected.tabId) const paneKey = makePaneKey(expected.tabId, expected.leafId) return Boolean( - tab && + session && session.terminalLayoutsByTabId[expected.tabId]?.ptyIdsByLeafId?.[expected.leafId] === expected.ptyId && session.terminalPtyIncarnationsByPaneKey?.[paneKey] === expected.incarnationId diff --git a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index 63135f261b7..3e8d618e8a4 100644 --- a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts +++ b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts @@ -1,5 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { splitFreebuffScreenUpdates } from '../../shared/freebuff-screen-status' import { OrcaRuntimeWithSerializeMainTerminalBuffer } from './orca-runtime-serialize-main-terminal-buffer' +import { observeFreebuffTerminalStatus } from './freebuff-terminal-status' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import { detectAgentStatusFromTitle, normalizeTerminalTitle } from '../../shared/agent-detection' import { shouldModelAnswerHiddenPtyQueries } from './terminal-model-query-authority' @@ -171,7 +173,26 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime // Why inside the chain: the ownership mirror must observe live bytes in // the same total order as seeds (seedOwner also runs on this chain). state.ownership.scan(data) - await state.emulator.write(data, { forwardQueryReplies }) + for (const chunk of splitFreebuffScreenUpdates(data, state.emulator.partialEscapeTailAnsi)) { + await state.emulator.write(chunk, { forwardQueryReplies }) + const pty = this.ptysById.get(ptyId) + if (pty && !pty.connectionId && this.headlessTerminals.get(ptyId) === state) { + const payload = observeFreebuffTerminalStatus( + state.emulator, + chunk, + this.terminalSpawnCommandsByPtyId.get(ptyId), + pty.launchAgent + ) + if (payload) { + pty.lastExplicitAgentStatus = { state: payload.state, updatedAt: Date.now() } + this.emitTerminalAgentStatusEvents(ptyId, { + cleanData: '', + payloads: [payload], + lastPayloadCleanOffset: null + }) + } + } + } state.outputSequence = outputSequence }) // Legacy callers remain best-effort; bounded SSH admission observes the raw receipt. diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index 160c99f2071..57b094165da 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -16,6 +16,14 @@ import { import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecutionContext { + /** Arrival-order mode scan: the settled tracker plus any in-flight snapshot capture's. */ + protected scanProviderModeTrackers(ptyId: string, data: string): void { + this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) + for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { + tracker.scan(data) + } + } + onPtyData( ptyId: string, data: string, @@ -27,10 +35,7 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution ): number { const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) - this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) - for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { - tracker.scan(data) - } + this.scanProviderModeTrackers(ptyId, data) const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) const cwd = osc7Metadata.cwd const cwdChanged = osc7Metadata.cwdChanged diff --git a/src/main/runtime/orca-runtime-on-pty-exit.ts b/src/main/runtime/orca-runtime-on-pty-exit.ts index 99fd88373fa..52b7024b818 100644 --- a/src/main/runtime/orca-runtime-on-pty-exit.ts +++ b/src/main/runtime/orca-runtime-on-pty-exit.ts @@ -117,10 +117,9 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null ) } - const intentionalStopIncarnation = this.intentionalHandlelessPtyStops.get(ptyId) - const preservesIntentionalHandlelessSurface = - this.intentionalHandlelessPtyStops.has(ptyId) && - (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) + // Why both kinds: a sleep keeps its wake hint, and a restart's replacement takes the pane. + const preservesIntentionallyStoppedSurface = + this.intentionalPtyStops.claimExit(ptyId, exitIncarnationId ?? pty?.incarnationId).length > 0 advertisedUrlWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) @@ -217,7 +216,7 @@ export class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDisconnecte this.pruneDisconnectedPtyTranscript(pty) } let retirement: Promise | undefined - if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { + if (preservesIntentionallyStoppedSurface || preservesAbnormalSshSurface) { // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } else { diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 50b1f52d238..75005726c2d 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -216,9 +216,10 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime } // Why: group address resolution (Section 4.5) queries per-handle status and must not throw on stale handles; return null on any error. - getAgentStatusForHandle(handle: string): string | null { + async getAgentStatusForHandle(handle: string): Promise { // A structured worker has no pane and no title, so every PTY probe below answers null and - // `@idle` would enumerate it and then silently drop it. Its status is the journal's. + // `@idle` would enumerate it and then silently drop it. Its status is the journal's, read + // through a conversation the idle sweep may have closed. const structured = resolveStructuredWorkerAuthority(handle, this._orchestrationDb) if (structured) { return structuredWorkerAgentStatus(structured.identity.sessionId) diff --git a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts index de98831cdd5..b09ca3b7b8d 100644 --- a/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts +++ b/src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts @@ -146,6 +146,7 @@ export class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends OrcaRunt this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.terminalRunFacts.delete(ptyId) this.disposePtyTitleTracker(ptyId) this.invalidatePtyIncarnationHandle(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index 31c1bc38627..ba7b47be72d 100644 --- a/src/main/runtime/orca-runtime-register-pty.ts +++ b/src/main/runtime/orca-runtime-register-pty.ts @@ -141,6 +141,8 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand currentFence.pendingRegistration = false } } + // Why: a listed surface's pending-handle → ready flip must not wait on a later renderer graph change. + this.touchMobileSessionSnapshotsForPty(ptyId) // Why: the renderer's own PTY spawn is the reliable signal that the pending // mobile create's tab is live; publish its surface main-side (#7587). if (binding && paneKey) { diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index ad7c81f2e87..04cb91e0281 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -61,7 +61,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { return this.terminalWriter.writeAction(ptyId, action, payload, options) } @@ -69,7 +69,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O protected writeTerminalInputChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { return this.terminalWriter.writeChunks(ptyId, text, options) } diff --git a/src/main/runtime/orca-runtime-resolve-terminal-pane.ts b/src/main/runtime/orca-runtime-resolve-terminal-pane.ts index 1093319783a..60047604e43 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-pane.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-pane.ts @@ -184,7 +184,7 @@ export class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWithGetTermin ): Promise { // Before the PTY lookup, because a structured worker has no PTY and no leaf: without this the // only peer read verb answers `terminal_handle_stale` for a perfectly live worker. - const structured = readStructuredWorkerTerminal({ + const structured = await readStructuredWorkerTerminal({ handle, db: this.getOrchestrationDbIfAvailable?.() ?? null, ...(opts.cursor === undefined ? {} : { cursor: opts.cursor }), diff --git a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts index 49d55d96c17..6acf8ba325f 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts @@ -100,7 +100,7 @@ export class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRunt return await this.claudeAgentTeams.handleTmuxCompat(request, { splitTerminal: (handle, opts) => this.splitTerminal(handle, opts), readTerminal: (handle, opts) => this.readTerminal(handle, opts), - sendTerminal: (handle, action) => this.sendTerminal(handle, action), + sendTerminal: (handle, action, options) => this.sendTerminal(handle, action, options), focusTerminal: (handle) => this.focusTerminal(handle), closeTerminal: (handle) => this.closeTerminal(handle), showTerminal: (handle) => this.showTerminal(handle) diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts index bfab8f0ba9a..8e9ad65dc32 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts @@ -123,9 +123,9 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) { return } - if (this.ptyController?.write(pty.ptyId, command)) { + if (this.ptyController?.write(pty.ptyId, command, 'launch')) { // Why: Enter rides its own write so a long command cannot swallow it. - this.ptyController.write(pty.ptyId, '\r') + this.ptyController.write(pty.ptyId, '\r', 'launch') this.noteTerminalSpawnCommand(pty.ptyId, command) } } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index e4f38e058d3..4b252d57fef 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -45,6 +45,8 @@ import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoin import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry' import { RuntimeTerminalWriter } from './runtime-terminal-writer' import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' +import { TerminalIntentionalStops } from './terminal-intentional-stops' +import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts' import type { TuiIdleEvidenceSource } from './tui-idle-evidence' import { TUI_IDLE_DEFAULT_TIMEOUT_MS, @@ -233,9 +235,16 @@ export class OrcaRuntimeWithRuntimeId { protected pendingPtyRegistrationIncarnations = new Map() - // Why: exact-stop is the current sleep transaction boundary; its exit must - // leave the renderer's intentional sleeping surface available for wake. - protected intentionalHandlelessPtyStops = new Map() + // Why public: the PTY IPC layer's stop paths write it and its exit delivery reads it. + readonly intentionalPtyStops = new TerminalIntentionalStops() + + readonly terminalRunFacts = new TerminalRunFactsRegister() + + /** Both spawn-commit funnels report each committed process here, once. */ + noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { + this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding) + this.intentionalPtyStops.noteSpawnCommit(commit.id) + } // Why: coalesces title/status-driven session.tabs emits so spinner churn // doesn't fan out (and per-client JSON.stringify) a snapshot several times a @@ -322,7 +331,7 @@ export class OrcaRuntimeWithRuntimeId { protected readonly terminalWaiters = new RuntimeTerminalWaiterRegistry() protected readonly terminalWriter = new RuntimeTerminalWriter( - (ptyId, data) => this.ptyController?.write(ptyId, data) ?? false, + (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false, (ptyId) => this.getPtyWriteHostPlatform(ptyId), (ptyId) => this.getPtyAgent(ptyId) ) diff --git a/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts b/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts index 7994f7d8857..53a9b4ffc59 100644 --- a/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts +++ b/src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts @@ -80,6 +80,16 @@ export class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRuntimeWithA return { handle, cleared: true } } + async resetTerminalInputModes(handle: string): Promise<{ handle: string; reset: boolean }> { + const leaf = this.resolveLeafForHandle(handle) + if (!leaf?.ptyId) { + throw new Error('terminal_not_found') + } + await this.ptyController?.resetInputModes?.(leaf.ptyId) + await this.resetHeadlessTerminalInputModes(leaf.ptyId) + return { handle, reset: true } + } + getTerminalSize(ptyId: string): { cols: number; rows: number } | null { return this.ptyController?.getSize?.(ptyId) ?? null } diff --git a/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts b/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts index 7d3645473bc..0f0a856bda2 100644 --- a/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts +++ b/src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts @@ -64,7 +64,7 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi const pendingPtyIds = new Set() let generation = 0 let fullyCommitted = false - let releaseReversibleRendererStops = (): void => {} + const settleReversibleStops = new Map void>() try { const resolvedWorktrees = includeTargetResolvedWorktree( [...(await this.getResolvedWorktreeMap()).values()], @@ -148,16 +148,25 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi const stopAndWait = ptyController.stopAndWait.bind(ptyController) const orderedLivePtyIds = [...livePtyIds].sort() - releaseReversibleRendererStops = - ptyController.markReversibleStops?.(orderedLivePtyIds) ?? (() => {}) - const stopResults = await Promise.allSettled( - orderedLivePtyIds.map(async (ptyId) => ({ + for (const ptyId of orderedLivePtyIds) { + settleReversibleStops.set( ptyId, - stopped: await stopAndWait(ptyId, { + this.intentionalPtyStops.mark( + ptyId, + 'reversible', + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + ) + } + const stopResults = await Promise.allSettled( + orderedLivePtyIds.map(async (ptyId) => { + const stopped = await stopAndWait(ptyId, { keepHistory: true, deadlineMs: teardownRpcDeadline(sleepDeadline) }) - })) + settleReversibleStops.get(ptyId)?.(stopped) + return { ptyId, stopped } + }) ) const successfulStopPtyIds = orderedLivePtyIds.filter((_, index) => { const result = stopResults[index] @@ -251,7 +260,9 @@ export class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends OrcaRuntimeWi postStopVerified: true } } finally { - releaseReversibleRendererStops() + for (const settleStop of settleReversibleStops.values()) { + settleStop(false) + } if (!fullyCommitted && generation > 0) { const cancelledPtyIds = [...pendingPtyIds].sort() if (cancelledPtyIds.length > 0) { diff --git a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts index a96ae64e596..d854be6ec68 100644 --- a/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts @@ -47,18 +47,22 @@ export class OrcaRuntimeWithStopExactTerminalsForWorktree extends OrcaRuntimeWit const stoppedPtyIds: string[] = [] for (const ptyId of [...expected].sort()) { - if (opts.keepHistory) { - this.intentionalHandlelessPtyStops.set( - ptyId, - this.ptysById.get(ptyId)?.incarnationId ?? null - ) - } + // Why: exact-stop is the sleep transaction boundary; its exit must leave the sleeping surface for wake. + const settleStop = opts.keepHistory + ? this.intentionalPtyStops.mark( + ptyId, + 'reversible', + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + : null + let stopped = false try { - if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { - throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) - } + stopped = await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }) } finally { - this.intentionalHandlelessPtyStops.delete(ptyId) + settleStop?.(stopped) + } + if (!stopped) { + throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) } stoppedPtyIds.push(ptyId) } diff --git a/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts b/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts index 2def6219d29..1b98c67b8be 100644 --- a/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts @@ -175,10 +175,13 @@ function arrange(entry: Entry, condition: Condition): CloseContinuityHarness { async function closePane(entry: Entry, harness: CloseContinuityHarness): Promise { if (entry === 'renderer') { - await harness.runtime.closeTerminalSurfaceFromRenderer(WORKTREE_ID, { - kind: 'pane', - tabId: TAB_ID, - leafId: LEAF_ID + await harness.runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'pane', + tabId: TAB_ID, + leafId: LEAF_ID + } }) return } diff --git a/src/main/runtime/orca-runtime-terminal-close-records.test.ts b/src/main/runtime/orca-runtime-terminal-close-records.test.ts new file mode 100644 index 00000000000..f05ac78957a --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-close-records.test.ts @@ -0,0 +1,309 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { + CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS, + MAX_CLOSED_TERMINAL_TAB_TOMBSTONES +} from '../../shared/closed-terminal-tab-tombstones' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { Store } from '../persistence/loading-store/store' +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { OrcaRuntimeService } from './orca-runtime' +import { + LEAF_ID, + REPO_ID, + TAB_ID, + WORKTREE_ID, + WORKTREE_PATH, + makeSession +} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' +import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' + +const SSH_REPO_ID = 'ssh-repo' +const SSH_HOST_ID = 'ssh:target-1' +const SSH_WORKTREE_ID = `${SSH_REPO_ID}::/srv/app` +const LATE_TAB_ID = '6f0a5c8e-2b1d-4c3e-9f7a-1d2e3f4a5b6c' + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createPersistedRuntime(session: WorkspaceSessionState = makeSession()) { + const directory = mkdtempSync(join(tmpdir(), 'orca-close-records-')) + directories.push(directory) + const dataFile = join(directory, 'orca-data.json') + const store = createSqliteTestStore(Store, { dataFile }) + store.addRepo({ + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'Fixture', + badgeColor: 'gray', + addedAt: 1 + }) + store.addRepo({ + id: SSH_REPO_ID, + path: '/srv/app', + displayName: 'Remote', + badgeColor: 'gray', + addedAt: 1, + connectionId: 'target-1' + }) + store.setWorkspaceSession(advanceTerminalTopologyRevision(session, WORKTREE_ID)) + store.flushOrThrow() + return { + store, + runtime: new OrcaRuntimeService(store), + reload: async () => { + store.flush() + store.freezeWrites() + await store.waitForPendingWrite() + return createSqliteTestStore(Store, { dataFile }) + } + } +} + +/** A renderer save: membership as the renderer holds it, and no close records, which it never sends. */ +function rendererSave(session: WorkspaceSessionState): WorkspaceSessionState { + const { + terminalTopologyRevisionByRepoId: _hostPrivate, + closedTerminalTabTombstonesByTabId: _mainOwned, + ...rendererView + } = session + return { ...rendererView, tabsByWorktree: { [WORKTREE_ID]: [] }, terminalLayoutsByTabId: {} } +} + +describe('close records', () => { + it.each(['user', 'cleanup'] as const)( + 'records a %s close in main and keeps it across a renderer save and a reload', + async (reason) => { + const { store, runtime, reload } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: TAB_ID }, + reason + }) + store.setWorkspaceSession(rendererSave(store.getWorkspaceSession())) + + const reloaded = (await reload()).getWorkspaceSession() + expect(reloaded.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(reloaded.closedTerminalTabTombstonesByTabId?.[TAB_ID]).toEqual({ + closedAt: expect.any(Number), + worktreeId: WORKTREE_ID, + reason + }) + } + ) + + // The store keeps main's map only when a write omits it; main's own writes carry it and win. + it("keeps main's own record writes across later store writes", async () => { + const { store, runtime } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: TAB_ID } + }) + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: LATE_TAB_ID } + }) + store.setWorkspaceSession(rendererSave(store.getWorkspaceSession())) + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}).sort() + ).toEqual([LATE_TAB_ID, TAB_ID].sort()) + }) + + it("keeps a close's first reason when the renderer's echo closes the same tab again", async () => { + const { store, runtime } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: LATE_TAB_ID }, + reason: 'cleanup' + }) + const first = store.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[LATE_TAB_ID] + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: LATE_TAB_ID }, + reason: 'user' + }) + + expect(first?.reason).toBe('cleanup') + expect(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[LATE_TAB_ID]).toEqual( + first + ) + }) + + it('lets a close that removes a listed tab replace a record that tab already had', async () => { + const earlier = Date.now() - 24 * 60 * 60 * 1000 + const { store, runtime } = createPersistedRuntime({ + ...makeSession(), + closedTerminalTabTombstonesByTabId: { + [TAB_ID]: { closedAt: earlier, worktreeId: WORKTREE_ID, reason: 'cleanup' } + } + }) + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: TAB_ID }, + reason: 'user' + }) + + const record = store.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[TAB_ID] + expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(record?.reason).toBe('user') + expect(record?.closedAt).toBeGreaterThan(earlier) + }) + + it('admits a late spawn for a tab whose close record is past the TTL', async () => { + const expired = { + [LATE_TAB_ID]: { + closedAt: Date.now() - CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS - 60_000, + worktreeId: WORKTREE_ID, + reason: 'user' as const + } + } + const { store } = createPersistedRuntime({ + ...makeSession(), + closedTerminalTabTombstonesByTabId: expired + }) + + expect( + await store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: LATE_TAB_ID, + leafId: LEAF_ID, + ptyId: 'late-pty', + incarnationId: 'late-incarnation' + }) + ).toBe(true) + }) + + it('records nothing for a split pane close, which leaves its tab open', async () => { + const { store, runtime } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'pane', + tabId: 'unknown-tab', + leafId: LEAF_ID + } + }) + + expect(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId).toBeUndefined() + }) + + // Why: only a resolved tab close records; a pane target never widens here, even on the last pane. + it("records nothing for a pane close aimed at its tab's only pane", async () => { + const { store, runtime } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'pane', + tabId: TAB_ID, + leafId: LEAF_ID + } + }) + + expect(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId).toBeUndefined() + expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([ + TAB_ID + ]) + }) + + // The durable half of refusing a late graft: the close lands while the tab's spawn is in + // flight, so main has never listed the tab, and the spawn commits only after a relaunch. + it('refuses a closed tab whose spawn commits after a crash and reload', async () => { + const { runtime, reload } = createPersistedRuntime() + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: LATE_TAB_ID } + }) + const relaunched = await reload() + + expect( + await relaunched.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: LATE_TAB_ID, + leafId: LEAF_ID, + ptyId: 'late-pty', + incarnationId: 'late-incarnation' + }) + ).toBe(false) + expect( + relaunched.getWorkspaceSession().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id) + ).not.toContain(LATE_TAB_ID) + }) + + it('refuses the graft when the close was recorded in another host partition', async () => { + const { store, runtime } = createPersistedRuntime() + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }, + SSH_HOST_ID + ) + + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: SSH_WORKTREE_ID, + target: { + kind: 'tab', + tabId: LATE_TAB_ID + } + }) + + expect( + store.getWorkspaceSession(SSH_HOST_ID).closedTerminalTabTombstonesByTabId?.[LATE_TAB_ID] + ).toBeDefined() + // A relay reattach binds into `local`, not the partition the close was recorded in. + expect( + await store.persistPtyBinding({ + worktreeId: SSH_WORKTREE_ID, + tabId: LATE_TAB_ID, + leafId: LEAF_ID, + ptyId: 'ssh:target-1@@pty2:epoch:1', + incarnationId: 'relay-incarnation' + }) + ).toBe(false) + }) + + it('prunes each host partition alone, so local churn evicts no SSH record', async () => { + const { store, runtime } = createPersistedRuntime() + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }, + SSH_HOST_ID + ) + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: SSH_WORKTREE_ID, + target: { + kind: 'tab', + tabId: 'ssh-tab' + } + }) + + for (let index = 0; index <= MAX_CLOSED_TERMINAL_TAB_TOMBSTONES; index += 1) { + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'tab', + tabId: `local-${index}` + } + }) + } + + expect( + Object.keys(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId ?? {}) + ).toHaveLength(MAX_CLOSED_TERMINAL_TAB_TOMBSTONES) + expect( + store.getWorkspaceSession(SSH_HOST_ID).closedTerminalTabTombstonesByTabId?.['ssh-tab'] + ).toBeDefined() + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts index bac59e614b2..101ad7d4e4e 100644 --- a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts +++ b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' +import { makePaneKey } from '../../shared/stable-pane-id' const PTY_ID = 'ssh:target@@relay-pty' const WORKTREE_ID = 'repo::/worktree' const TAB_ID = 'tab-terminal' const LEAF_ID = '11111111-1111-4111-8111-111111111111' +/** A runtime whose pty controller captures every write for assertion. */ function makeRuntime(): { runtime: OrcaRuntimeService; writes: string[] } { const writes: string[] = [] const runtime = new OrcaRuntimeService(null) @@ -20,6 +22,7 @@ function makeRuntime(): { runtime: OrcaRuntimeService; writes: string[] } { return { runtime, writes } } +/** Attach a window and publish a one-tab, one-leaf terminal graph. */ function syncGraph(runtime: OrcaRuntimeService): void { runtime.attachWindow(1) runtime.syncWindowGraph(1, { @@ -44,6 +47,7 @@ function syncGraph(runtime: OrcaRuntimeService): void { }) } +/** (Re)register the fixture pty leaf under the given incarnation id. */ function register(runtime: OrcaRuntimeService, incarnationId: string): void { runtime.registerPty(PTY_ID, WORKTREE_ID, 'target', { tabId: TAB_ID, @@ -66,6 +70,8 @@ describe('runtime terminal handle incarnation fencing', () => { inspectProcess }) expect(runtime.markRendererReloading(1)).not.toBeNull() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. expect((runtime as unknown as { handles: Map }).handles.has(handle)).toBe( false ) @@ -89,6 +95,8 @@ describe('runtime terminal handle incarnation fencing', () => { }) }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. it('treats a null-to-known incarnation as the same un-fenced PTY', async () => { const { runtime } = makeRuntime() const handle = runtime.preAllocateHandleForPty(PTY_ID) @@ -132,6 +140,8 @@ describe('runtime terminal handle incarnation fencing', () => { // Rotate the record directly so reconcile is the only fence exercised. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test reaches the runtime's protected pty record map to bypass the registerPty fence. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. const internals = runtime as unknown as { ptysById: Map } @@ -159,12 +169,16 @@ describe('runtime terminal handle incarnation fencing', () => { }) expect(replacement?.handle).not.toBe(staleHandle) await expect(runtime.readTerminal(staleHandle)).rejects.toThrow('terminal_handle_stale') - await expect(runtime.sendTerminal(staleHandle, { text: 'stale input' })).rejects.toThrow( - 'terminal_handle_stale' - ) + await expect( + runtime.sendTerminal(staleHandle, { text: 'stale input' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_handle_stale') await expect( - runtime.sendTerminal(replacement!.handle, { text: 'replacement input' }) + runtime.sendTerminal( + replacement!.handle, + { text: 'replacement input' }, + { inputKind: 'driving' } + ) ).resolves.toMatchObject({ accepted: true, handle: replacement!.handle @@ -215,6 +229,8 @@ describe('runtime terminal handle incarnation fencing', () => { runtime.registerPreAllocatedHandleForPty(PTY_ID, replacementHandle) syncGraph(runtime) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. const internals = runtime as unknown as { handles: Map handleByLeafKey: Map @@ -237,3 +253,130 @@ describe('runtime terminal handle incarnation fencing', () => { }) }) }) + +describe('resolveTerminalHandleByProcessIncarnation direct fencing', () => { + const LOCAL_SCOPE = JSON.stringify({ kind: 'local', hostId: 'local' }) + // PTY_ID ('ssh:target@@relay-pty') derives connectionId 'target' via parseAppSshPtyId, so its + // host scope is ssh:target rather than local. + const SSH_TARGET_SCOPE = JSON.stringify({ kind: 'ssh', targetId: 'target' }) + + /** Register a pty leaf directly, optionally under a connection and incarnation. */ + function seedPty( + runtime: OrcaRuntimeService, + ptyId: string, + incarnationId: string | null, + connectionId: string | null = null + ): void { + runtime.registerPty(ptyId, WORKTREE_ID, connectionId, { + tabId: TAB_ID, + leafId: LEAF_ID, + ...(incarnationId ? { incarnationId } : {}) + }) + } + + function resolve( + runtime: OrcaRuntimeService, + processIncarnation: string, + serializedHostScope: string | null + ): string | null { + return runtime.resolveTerminalHandleByProcessIncarnation( + processIncarnation, + serializedHostScope + ) + } + + it('mints a live handle for a pty whose exact incarnation and host scope match', () => { + const { runtime } = makeRuntime() + seedPty(runtime, PTY_ID, 'incarnation-1') + const handle = resolve(runtime, `${PTY_ID}:incarnation-1`, SSH_TARGET_SCOPE) + expect(handle).toMatch(/^term_/) + // Re-minting the same live pty is idempotent. + expect(resolve(runtime, `${PTY_ID}:incarnation-1`, SSH_TARGET_SCOPE)).toBe(handle) + }) + + it('returns null when the recorded incarnationId no longer matches the live pty', () => { + const { runtime } = makeRuntime() + seedPty(runtime, PTY_ID, 'incarnation-1') + expect(resolve(runtime, `${PTY_ID}:incarnation-2`, LOCAL_SCOPE)).toBeNull() + }) + + it('returns null when the live pty has no incarnationId (legacy runtimeId:ptyId:gen never reaps)', () => { + const { runtime } = makeRuntime() + seedPty(runtime, PTY_ID, null) + // A modern-shaped probe cannot match a pty that carries no incarnationId... + expect(resolve(runtime, `${PTY_ID}:incarnation-1`, LOCAL_SCOPE)).toBeNull() + // ...and the legacy `${runtimeId}:${ptyId}:${ptyGeneration}` shape stays fail-closed rather + // than reap on a ptyGeneration guess. + expect(resolve(runtime, `runtime_test:${PTY_ID}:0`, LOCAL_SCOPE)).toBeNull() + }) + + it('returns null when the host scope does not match', () => { + const { runtime } = makeRuntime() + seedPty(runtime, PTY_ID, 'incarnation-1') + expect( + resolve(runtime, `${PTY_ID}:incarnation-1`, JSON.stringify({ kind: 'ssh', targetId: 'nope' })) + ).toBeNull() + }) + + it('returns null when no host scope is supplied (fails closed)', () => { + const { runtime } = makeRuntime() + seedPty(runtime, PTY_ID, 'incarnation-1') + expect(resolve(runtime, `${PTY_ID}:incarnation-1`, null)).toBeNull() + }) + + it('resolves a colon-bearing SSH/relay incarnationId that lastIndexOf would mis-split', () => { + const { runtime } = makeRuntime() + // PTY_ID already carries ':' and '@@'; the incarnationId itself also carries colons. + seedPty(runtime, PTY_ID, 'relay:conn-3:incarnation-9', 'target') + const handle = resolve( + runtime, + `${PTY_ID}:relay:conn-3:incarnation-9`, + JSON.stringify({ kind: 'ssh', targetId: 'target' }) + ) + expect(handle).toMatch(/^term_/) + }) + + it('resolves a Windows repo::C:\\path@@1 ptyId', () => { + const { runtime } = makeRuntime() + const windowsPtyId = 'repo::C:\\path@@1' + seedPty(runtime, windowsPtyId, 'incarnation-win') + const handle = resolve(runtime, `${windowsPtyId}:incarnation-win`, LOCAL_SCOPE) + expect(handle).toMatch(/^term_/) + }) + + it('keeps scanning past a colon-ambiguous decoy in another host scope to the genuine match', () => { + const { runtime } = makeRuntime() + // One process-incarnation string, two colon-ambiguous pty ids that both satisfy the exact + // matcher: 'relay' + 'conn:incarnation-1' AND 'relay:conn' + 'incarnation-1' both stringify to + // 'relay:conn:incarnation-1'. The decoy is registered FIRST and lives in a different host scope + // (ssh:decoy); the genuine pty is local and registered later. + const processIncarnation = 'relay:conn:incarnation-1' + const DECOY_TAB_ID = 'tab-decoy' + const DECOY_LEAF_ID = '22222222-2222-4222-8222-222222222222' + const GENUINE_TAB_ID = 'tab-genuine' + const GENUINE_LEAF_ID = '33333333-3333-4333-8333-333333333333' + runtime.registerPty('relay', WORKTREE_ID, 'decoy', { + tabId: DECOY_TAB_ID, + leafId: DECOY_LEAF_ID, + incarnationId: 'conn:incarnation-1' + }) + runtime.registerPty('relay:conn', WORKTREE_ID, null, { + tabId: GENUINE_TAB_ID, + leafId: GENUINE_LEAF_ID, + incarnationId: 'incarnation-1' + }) + + // Before the fix, the earlier decoy's host-scope mismatch returned null and suppressed the + // genuine same-scope pty entirely. `continue` lets the scan reach it. + const handle = resolve(runtime, processIncarnation, LOCAL_SCOPE) + expect(handle).toMatch(/^term_/) + // getTerminalProcessIncarnation cannot separate the two (both stringify to + // 'relay:conn:incarnation-1'), so pin the remint to the GENUINE pane. The paneKey is the only + // terminal-specific observable that distinguishes the genuine leaf from the same-incarnation + // decoy; this fails if the resolver ever mints the decoy's handle instead of the genuine pty's. + const genuinePaneKey = makePaneKey(GENUINE_TAB_ID, GENUINE_LEAF_ID) + const decoyPaneKey = makePaneKey(DECOY_TAB_ID, DECOY_LEAF_ID) + expect(runtime.getTerminalPaneKey(handle!)).toBe(genuinePaneKey) + expect(runtime.getTerminalPaneKey(handle!)).not.toBe(decoyPaneKey) + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts index 063866c83c5..3a91794f441 100644 --- a/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts +++ b/src/main/runtime/orca-runtime-terminal-retirement-host-partition.test.ts @@ -413,6 +413,62 @@ describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', expect(targets.get(folderWorktreeId)).toBe(localSession) }) + it.each<[string, string, Record, ExecutionHostId]>([ + ['local repo', 'repo::/wt', { id: 'repo' }, LOCAL_EXECUTION_HOST_ID], + ['SSH repo', 'repo::/wt', { id: 'repo', connectionId: 'c1' }, 'ssh:c1'], + ['runtime repo', 'repo::/wt', { id: 'repo', executionHostId: 'runtime:e1' }, 'runtime:e1'], + ['local folder', 'folder:f1', { folderId: 'f1' }, LOCAL_EXECUTION_HOST_ID], + ['SSH folder', 'folder:f1', { folderId: 'f1', connectionId: 'c1' }, 'ssh:c1'], + ['explicit SSH folder', 'folder:f1', { folderId: 'f1', executionHostId: 'ssh:c2' }, 'ssh:c2'], + ['runtime folder', 'folder:f1', { folderId: 'f1', executionHostId: 'runtime:e1' }, 'runtime:e1'] + ])('reads a %s own saved partition', (_label, worktreeId, owner, ownerHostId) => { + const listing = (id: string) => ({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [ + { + id, + ptyId: null, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + const sessions = new Map([ + [ownerHostId, listing('own')], + ['runtime:e0', listing('rotated')] + ]) + const { folderId, ...repoOrFolder } = owner + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller reads only repos, folder workspaces and workspace sessions. + const store = { + getRepos: () => (folderId ? [] : [repoOrFolder]), + getRepo: () => (folderId ? undefined : repoOrFolder), + getFolderWorkspaces: () => (folderId ? [{ ...repoOrFolder, id: folderId }] : []), + getWorkspaceSessionHostIds: () => [...sessions.keys()], + getWorkspaceSession: (hostId: ExecutionHostId) => + sessions.get(hostId) ?? getDefaultWorkspaceSession() + } as never + const controller = new RuntimeWorkspaceSessionController({ + getStore: () => store, + resolveFolderConnectionId: (workspace) => workspace.connectionId ?? null, + hasRuntimeOwnedPtyCandidate: () => false + }) + + expect(controller.getOwnPartition(worktreeId)).toBe(sessions.get(ownerHostId)) + expect(controller.get(worktreeId)).toBe(sessions.get(ownerHostId)) + + sessions.set(ownerHostId, getDefaultWorkspaceSession()) + const fellBack = controller.get(worktreeId) === sessions.get('runtime:e0') + // Only a runtime owner rotates onto another copy; that copy is never read as its own. + expect(fellBack).toBe(ownerHostId.startsWith('runtime:')) + expect(controller.getOwnPartition(worktreeId)?.tabsByWorktree[worktreeId]).toBeUndefined() + }) + it('waits for provider retirement on a direct worktree stop', async () => { const harness = partitionedStore() const runtime = new OrcaRuntimeService(harness.store) diff --git a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts index df39087c688..c9bd30b7199 100644 --- a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts +++ b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts @@ -123,7 +123,10 @@ describe('renderer close intents', () => { it('keeps a closed tab closed across a renderer save and a reload', async () => { const { store, runtime, reload } = createPersistedRuntime(makeSession()) - await runtime.closeTerminalSurfaceFromRenderer(WORKTREE_ID, { kind: 'tab', tabId: TAB_ID }) + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { kind: 'tab', tabId: TAB_ID } + }) store.setWorkspaceSession(rendererSaveWithout(store.getWorkspaceSession())) expect((await reload()).tabsByWorktree[WORKTREE_ID]).toEqual([]) @@ -132,10 +135,13 @@ describe('renderer close intents', () => { it('keeps a closed split pane closed across a renderer save and a reload', async () => { const { store, runtime, reload } = createPersistedRuntime(splitSession()) - await runtime.closeTerminalSurfaceFromRenderer(WORKTREE_ID, { - kind: 'pane', - tabId: TAB_ID, - leafId: LEAF_ID + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'pane', + tabId: TAB_ID, + leafId: LEAF_ID + } }) store.setWorkspaceSession(rendererSaveWithout(store.getWorkspaceSession(), LEAF_ID)) @@ -158,10 +164,13 @@ describe('renderer close intents', () => { ])('never widens a pane close into a tab close when %s', async (_case, session) => { const { runtime, reload } = createPersistedRuntime(session()) - await runtime.closeTerminalSurfaceFromRenderer(WORKTREE_ID, { - kind: 'pane', - tabId: TAB_ID, - leafId: LEAF_ID + await runtime.closeTerminalSurfaceFromRenderer({ + worktreeId: WORKTREE_ID, + target: { + kind: 'pane', + tabId: TAB_ID, + leafId: LEAF_ID + } }) expect((await reload()).tabsByWorktree[WORKTREE_ID]).toEqual([ diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts index 2501d0141dd..a1bcdf4b287 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-03.spec.ts @@ -85,7 +85,7 @@ describe('OrcaRuntimeService', () => { runtime.sendTerminal( terminal.handle, { text: 'notes', enter: true }, - { beforeWrite, afterWrite } + { inputKind: 'driving', beforeWrite, afterWrite } ) ).rejects.toThrow('terminal_not_writable') expect(writes).toEqual(['notes', '\r']) diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts index 23312f49420..310a1e12c81 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits.spec.ts @@ -150,10 +150,14 @@ describe('OrcaRuntimeService', () => { nextCursor: expect.any(String) }) - const send = await runtime.sendTerminal(terminal.handle, { - text: 'continue', - enter: true - }) + const send = await runtime.sendTerminal( + terminal.handle, + { + text: 'continue', + enter: true + }, + { inputKind: 'driving' } + ) expect(send).toMatchObject({ handle: terminal.handle, accepted: true diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts index 27421489aba..a47f037d40e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-13.spec.ts @@ -602,7 +602,7 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledTimes(2) expect(write.mock.calls[0][0]).toBe('pty-bare') expect(String(write.mock.calls[0][1])).toMatch(/codex/) - expect(write.mock.calls[1]).toEqual(['pty-bare', '\r']) + expect(write.mock.calls[1]).toEqual(['pty-bare', '\r', 'launch']) } finally { vi.useRealTimers() } diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index 9145bea46f6..d90dc63031b 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -467,7 +467,7 @@ describe('OrcaRuntimeService', () => { }) const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) - await runtime.sendTerminal(handle, { text: 'continue', enter: true }) + await runtime.sendTerminal(handle, { text: 'continue', enter: true }, { inputKind: 'driving' }) expect(writes).toEqual(['continue', '\r']) }) @@ -490,7 +490,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const prompt = 'line one\nline two\x1b[201~' - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) await vi.runAllTimersAsync() const result = await sendPromise @@ -535,6 +535,7 @@ describe('OrcaRuntimeService', () => { ) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'the brief', { + inputKind: 'driving', leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE }) await vi.runAllTimersAsync() @@ -601,6 +602,7 @@ describe('OrcaRuntimeService', () => { const assertAuthority = vi.fn() const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving', beforeWrite: assertAuthority }) await vi.advanceTimersByTimeAsync(500) @@ -659,7 +661,9 @@ describe('OrcaRuntimeService', () => { 'review this change', agent ) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) if (agent === 'omp') { await sendPromise expect(writes).toEqual([`${buildAgentPromptPasteBytes('review this change')}\r`]) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts index 8b27ce9bb5d..fe3687aab0a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-08.spec.ts @@ -44,7 +44,9 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect(runtime.isTerminalRunningSettledPromptAgent(handle)).resolves.toBe(true) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(1_199) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1_500) @@ -78,7 +80,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(renderGateCapMs('review this change') - 1) expect(writes).not.toContain('\r') @@ -117,7 +121,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'codex' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) await vi.advanceTimersByTimeAsync(8_000) expect(writes).not.toContain('\r') await vi.advanceTimersByTimeAsync(1_599) @@ -159,7 +165,9 @@ describe('OrcaRuntimeService', () => { launchAgent: 'claude' }) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') + const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', { + inputKind: 'driving' + }) // The marker at 100 ms re-arms the cap, but the ingest term is absolute: a prompt this // small is already ingested by then, so the fallback is one flat render timeout later. await vi.advanceTimersByTimeAsync(100 + 8_000 - 1) @@ -193,7 +201,7 @@ describe('OrcaRuntimeService', () => { }) const prompt = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\ntail` - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) await vi.runAllTimersAsync() const result = await sendPromise @@ -230,7 +238,7 @@ describe('OrcaRuntimeService', () => { }) const prompt = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1) - const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt, { inputKind: 'driving' }) const sendRejection = expect(sendPromise).rejects.toThrow('terminal_not_writable') await vi.runAllTimersAsync() @@ -258,7 +266,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const text = ['x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES), 'tail'].join('') - const result = await runtime.sendTerminal(handle, { text }) + const result = await runtime.sendTerminal(handle, { text }, { inputKind: 'driving' }) expect(result).toMatchObject({ handle, @@ -285,7 +293,7 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) const text = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\nline two\nline three` - await runtime.sendTerminal(handle, { text, enter: true }) + await runtime.sendTerminal(handle, { text, enter: true }, { inputKind: 'driving' }) expect(writes.at(-1)).toBe('\r') expect(writes.slice(0, -1).join('')).toBe(text) @@ -312,7 +320,7 @@ describe('OrcaRuntimeService', () => { vi.useFakeTimers() try { - const sendPromise = runtime.sendTerminal(handle, { text }) + const sendPromise = runtime.sendTerminal(handle, { text }, { inputKind: 'driving' }) expect(writes).toEqual([]) @@ -346,7 +354,11 @@ describe('OrcaRuntimeService', () => { const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) await expect( - runtime.sendTerminal(handle, { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }) + runtime.sendTerminal( + handle, + { text: 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) }, + { inputKind: 'driving' } + ) ).rejects.toThrow(TERMINAL_INPUT_TOO_LARGE_ERROR) expect(writes).toEqual([]) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts index ef0a1a96de5..ef895661c10 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import type { PtyProviderBufferSnapshot } from '../../providers/pty-provider-contract' +import { TerminalKittyKeyboardModeTracker } from '../../../shared/terminal-kitty-keyboard-mode-tracker' import { HEADLESS_LEAF_ID, TEST_WORKTREE_ID, @@ -199,6 +201,79 @@ describe('OrcaRuntimeService', () => { expect(snapshot?.data).not.toContain('line-0') }) + it('resets input modes through the PTY controller and the headless model', async () => { + const resetInputModes = vi.fn().mockResolvedValue(undefined) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resetInputModes + }) + syncSinglePty(runtime, 'pty-1') + // An app armed these and crashed with no command end. + runtime.onPtyData('pty-1', 'prompt$ app\r\n\x1b[>31u\x1b[?1000h\x1b[?2004h', 123) + const [terminal] = (await runtime.listTerminals()).terminals + const armed = await runtime.serializeTerminalBuffer('pty-1') + expect(armed?.kittyKeyboardFlags).toBe(31) + + await expect(runtime.resetTerminalInputModes(terminal.handle)).resolves.toEqual({ + handle: terminal.handle, + reset: true + }) + + expect(resetInputModes).toHaveBeenCalledWith('pty-1') + const snapshot = await runtime.serializeTerminalBuffer('pty-1') + expect(snapshot?.kittyKeyboardFlags).toBe(0) + expect(snapshot?.data).not.toContain('\x1b[?1000h') + expect(snapshot?.data).not.toContain('\x1b[?2004h') + }) + + it('grounds the provider mode tracker before a later chunk, in the emulator order', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime, 'pty-1') + runtime.onPtyData('pty-1', 'prompt$ ', 123) + const tracker = new TerminalKittyKeyboardModeTracker() + runtime['providerModeTrackersByPtyId'].set('pty-1', tracker) + + // A TUI starts while the reset still waits on the headless write chain. + const reset = runtime.resetHeadlessTerminalInputModes('pty-1') + runtime.onPtyData('pty-1', '\x1b[?1049h', 124) + await reset + await runtime['headlessTerminals'].get('pty-1')?.writeChain + + expect(runtime['headlessTerminals'].get('pty-1')?.emulator.isAlternateScreen).toBe(true) + expect(tracker.isAlternateScreen).toBe(true) + }) + + it('grounds an in-flight provider snapshot capture so it cannot publish the pre-reset screen', async () => { + let resolveSnapshot: (snapshot: PtyProviderBufferSnapshot) => void = () => {} + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeProviderBuffer: () => new Promise((resolve) => (resolveSnapshot = resolve)) + }) + syncSinglePty(runtime, 'pty-1') + const generation = runtime['getPtyLifecycleGeneration']('pty-1') + + // The capture's daemon request left before the reset; its answer predates the ground. + const capture = runtime['captureProviderTerminalBuffer']('pty-1', {}, generation) + await runtime.resetHeadlessTerminalInputModes('pty-1') + resolveSnapshot({ + data: '', + cols: 80, + rows: 24, + seq: 1, + source: 'headless', + alternateScreen: true + }) + await capture + + expect(runtime['providerModeTrackersByPtyId'].get('pty-1')?.isAlternateScreen).toBe(false) + }) + it('waits for terminal exit and resolves with the exit status', async () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts index 237afc57bc9..be454a5bde8 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-02.spec.ts @@ -142,7 +142,7 @@ describe('OrcaRuntimeService', () => { syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude agents' }) const [terminal] = (await runtime.listTerminals()).terminals - expect(runtime.getAgentStatusForHandle(terminal.handle)).toBeNull() + await expect(runtime.getAgentStatusForHandle(terminal.handle)).resolves.toBeNull() }) it('lists live terminals with fresh pane titles over stale tab titles', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts index 65757797f70..e122effd17d 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status.spec.ts @@ -230,7 +230,7 @@ describe('OrcaRuntimeService', () => { expect(new Set(handles).size).toBe(handles.length) await expect( - runtime.sendTerminal('term_victim', { text: 'for victim' }) + runtime.sendTerminal('term_victim', { text: 'for victim' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ accepted: true }) expect(writesByPty.get('pty-victim')).toEqual(['for victim']) expect(writesByPty.has('pty-imposter')).toBe(false) @@ -260,7 +260,7 @@ describe('OrcaRuntimeService', () => { const listed = await runtime.listTerminals() expect(listed.terminals[0]?.handle).toBe('term_already_bound') await expect( - runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + runtime.sendTerminal('term_already_bound', { text: 'still routed' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ accepted: true }) expect(writes).toEqual(['still routed']) // the reported-but-not-adopted handle must not resolve to the live pty @@ -347,7 +347,9 @@ describe('OrcaRuntimeService', () => { handle, tail: ['after unavailable'] }) - await expect(runtime.sendTerminal(handle, { text: 'still writable' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'still writable' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ handle, accepted: true }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index 1ebf15cbc24..dffd6e971eb 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -54,7 +54,7 @@ describe('OrcaRuntimeService', () => { tail: ['after restart'] }) await expect( - runtime.sendTerminal('term_exported', { text: 'still writable' }) + runtime.sendTerminal('term_exported', { text: 'still writable' }, { inputKind: 'driving' }) ).resolves.toMatchObject({ handle: 'term_exported', accepted: true @@ -179,7 +179,7 @@ describe('OrcaRuntimeService', () => { ]) expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1) - await runtime.sendTerminal('term_agent', { text: 'input' }) + await runtime.sendTerminal('term_agent', { text: 'input' }, { inputKind: 'driving' }) await runtime.updateRemoteDesktopViewer('pty-agent', 'viewer', 'client', 132, 41) expect(writes).toEqual([['pty-agent', 'input']]) expect(resize).toHaveBeenCalledWith('pty-agent', 132, 41) @@ -609,7 +609,10 @@ describe('OrcaRuntimeService', () => { condition: 'tui-idle', timeoutMs: 50 }) - await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledTimes(4)) + // The probe's read starts a microtask late; a 50 ms poll would race the wait's 50 ms timeout. + await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledTimes(4), { + interval: 1 + }) runtime.onPtyData('pty-legacy', '\x1b[H', Date.now()) lateReadySnapshot.resolve({ data: READY_SCREEN, diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts index 8980dc53f85..b9f605ccf91 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-07.spec.ts @@ -65,7 +65,7 @@ describe('OrcaRuntimeService', () => { const afterRestart = await restarted.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) const listed = await restarted.listTerminals(`id:${TEST_WORKTREE_ID}`) restarted.onPtyData('persisted-pty', 'after restart\n', 1) - await restarted.sendTerminal('term_current', { text: 'input' }) + await restarted.sendTerminal('term_current', { text: 'input' }, { inputKind: 'driving' }) await restarted.updateRemoteDesktopViewer('persisted-pty', 'viewer', 'client', 132, 41) expect(beforeRestart.tabs[0]).toMatchObject({ @@ -146,6 +146,58 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('terminal_topology_conflict') }) + // The client's retirement proofs die with a host restart; the close record does not. + it('refuses to adopt an orphan under a tab the user closed', async () => { + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [TEST_WORKTREE_ID]: [] }, + terminalTopologyRevisionByRepoId: { [TEST_REPO_ID]: 7 }, + closedTerminalTabTombstonesByTabId: { + 'tab-closed': { + closedAt: Date.now(), + worktreeId: TEST_WORKTREE_ID, + reason: 'user' as const + } + } + } + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService( + withDurableRuntimeStore({ ...runtimeStore, flushOrThrow: vi.fn() }) + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-closed', + incarnationId: 'inc-closed', + terminalHandle: 'term_closed', + title: 'shell', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + wslDistro: null + } + ] + }) + + await expect( + runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: 7, + claims: [ + { + terminal: 'term_closed', + ptyId: 'pty-closed', + incarnationId: 'inc-closed', + tabId: 'tab-closed', + leafId: HEADLESS_LEAF_ID + } + ] + }) + ).rejects.toThrow('terminal_orphan_surface_retired') + }) + it('keeps orphaned list and show writability aligned with the send gate', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ ...getDefaultWorkspaceSession(), @@ -183,7 +235,9 @@ describe('OrcaRuntimeService', () => { const shown = await runtime.showTerminal(entry!.handle) expect(shown.writable).toBe(true) - await expect(runtime.sendTerminal(entry!.handle, { text: 'hi' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(entry!.handle, { text: 'hi' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(writes).toEqual([['pty-orphan', 'hi']]) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index cc73ffa2718..5a5c9521403 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -418,7 +418,11 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-startup-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) it('keeps the 8s main-runtime startup readiness budget for agents without an override', async () => { @@ -554,7 +558,11 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() await Promise.resolve() - expect(write).toHaveBeenCalledWith('pty-opencode-draft-budget', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-opencode-draft-budget', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) it('rejects explicit startup commands for disabled selected agents', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index 2ec8e73f800..cc2c2cae2ba 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -83,7 +83,7 @@ describe('OrcaRuntimeService', () => { }) ) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r') + expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r', 'launch') }) }) @@ -526,7 +526,11 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-explicit-draft', '\x1b[?2004h›', Date.now()) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-explicit-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-explicit-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + 'launch' + ) }) }) diff --git a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts index 7a08f8f3bac..3c18eb68def 100644 --- a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts +++ b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts @@ -27,8 +27,8 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim snapshotVersion: snapshot.snapshotVersion + 1 }) if (options.immediate) { - // Why: readiness/lifecycle changes are structural and must not wait - // behind the title/status coalescing window. + // Why: an exit is structural and must not wait behind the title/status window. A + // registration's ready flip coalesces so it merges with the spawn's graph update. this.notifyMobileSessionTabsChanged(worktreeId) return } @@ -126,7 +126,11 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim tab.parentTabId, tab.leafId, tab.ptyId - ) + ) || + // Why: after a cold restore the saved session is the only membership record until the PTY + // registers. Frame-only; once listed, the surface's graph leaves pass the shared predicate + // like any listed surface's. Host-side single-writer membership absorbs this. + this.hasPersistedTerminalSurfaceMembership(snapshot.worktree, tab.parentTabId, tab.leafId) ) { continue } diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 5e90602755c..1597084eee1 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -27,7 +27,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso ptyId: string, generation: number, pastePayload: string, - options: RuntimeAgentPromptWriteOptions = {} + options: RuntimeAgentPromptWriteOptions ): Promise<{ submits: number; prompt?: RuntimeTerminalPromptDelivery }> { assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) @@ -62,7 +62,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // beginning when a large frame is split into independently processed chunks. renderGate?.arm() const initialWrite = submitWithPaste ? pastePayload + AGENT_PROMPT_SUBMIT : pastePayload - if (!this.ptyController?.write(ptyId, initialWrite)) { + if (!this.ptyController?.write(ptyId, initialWrite, options.inputKind)) { throw new Error('terminal_not_writable') } } catch (error) { @@ -103,7 +103,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso const baseline = preSubmitBaseline ?? this.getAgentPromptActivity(handle, ptyId, waitTextCache) this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) if (!submitWithPaste) { - if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { + if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } diff --git a/src/main/runtime/orchestration/coordinator-runtime-contract.ts b/src/main/runtime/orchestration/coordinator-runtime-contract.ts index 2f4f5bf9b5a..3c98fa30200 100644 --- a/src/main/runtime/orchestration/coordinator-runtime-contract.ts +++ b/src/main/runtime/orchestration/coordinator-runtime-contract.ts @@ -11,7 +11,7 @@ export type CoordinatorRuntime = { sendTerminalAgentPrompt( handle: string, prompt: string, - options?: DispatchPreambleSendOptions + options: DispatchPreambleSendOptions ): Promise listTerminals( worktreeSelector?: string, diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts index 439aca59b01..064ae577e0f 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-transfer.ts @@ -81,14 +81,30 @@ export function workerTerminalResourceHasIdentityConflict( ) } +/** Every resource whose process incarnation starts with `prefix`, newest first. */ +export function listWorkerTerminalResourcesByIncarnationPrefix( + this: OrchestrationDb, + prefix: string +): WorkerTerminalResourceRow[] { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table is exactly its row shape. + return this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE substr(process_incarnation, 1, ?) = ? ORDER BY updated_at DESC` + ) + .all(prefix.length, prefix) as WorkerTerminalResourceRow[] +} + export type WorkerTerminalTransferMethods = { findTransferableWorkerTerminalResource: typeof findTransferableWorkerTerminalResource workerTerminalResourceHasIdentityConflict: typeof workerTerminalResourceHasIdentityConflict + listWorkerTerminalResourcesByIncarnationPrefix: typeof listWorkerTerminalResourcesByIncarnationPrefix } export function attachWorkerTerminalTransfer(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { findTransferableWorkerTerminalResource, - workerTerminalResourceHasIdentityConflict + workerTerminalResourceHasIdentityConflict, + listWorkerTerminalResourcesByIncarnationPrefix }) } diff --git a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts index 8dbedc9b33c..52016aa1465 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-pty-write.ts @@ -24,7 +24,8 @@ export function writeOrchestrationPointerWithSettlement( return writeRefused('provider_cannot_settle') } try { - return settledWrite.call(args.controller, args.ptyId, args.data) + // Why driving: a pointer is input that tells a running agent to read its mail. + return settledWrite.call(args.controller, args.ptyId, args.data, 'driving') } catch { // A partial write that then threw cannot prove the transport took nothing. return writeUnverifiable('provider_threw_after_handoff', true) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 208868ce45b..a9fbb528939 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -147,12 +147,13 @@ ${params.taskSpec}` export type DispatchPreambleSendOptions = Pick< RuntimeAgentPromptWriteOptions, - 'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' + 'leadLine' | 'acceptQueued' | 'observationTimeoutMs' | 'requestId' | 'inputKind' > export function dispatchPreambleSendOptions(requestId: string): DispatchPreambleSendOptions { // Why: a delayed provider hook must not revoke an accepted Dispatch. return { + inputKind: 'driving', leadLine: ORCA_DISPATCH_PROMPT_LEAD_LINE, acceptQueued: true, observationTimeoutMs: 0, 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 048fc7732e4..52103cd5322 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -109,7 +109,7 @@ function harness(options: { resolveStructuredTarget: (mailboxHandle) => mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null, host: { - readGateFacts: () => (journal === null ? null : structuredSessionGateFacts(journal)), + readGateFacts: async () => (journal === null ? null : structuredSessionGateFacts(journal)), currentFence: () => 4, send } @@ -329,7 +329,7 @@ describe('forgetting one settled worker', () => { : null }, host: { - readGateFacts: () => structuredSessionGateFacts(journal), + readGateFacts: async () => structuredSessionGateFacts(journal), 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 62a555d4119..d3afed4923f 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -49,8 +49,8 @@ export type StructuredPointerSendOutcome = | { kind: 'unattached' } export type StructuredMailboxPointerHost = { - /** The idle gate, read off the session's full reduced timeline; `null` when it is not attached. */ - readGateFacts: (sessionId: string) => StructuredSessionGateFacts | null + /** The idle gate, read off the session's full reduced timeline; `null` when it cannot be read. */ + readGateFacts: (sessionId: string) => Promise send: (input: { sessionId: string dispatchId: string | null @@ -184,7 +184,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< reservedTypes: ReadonlySet | undefined ): Promise { const sessionId = target.sessionId - const session = this.deps.host.readGateFacts(sessionId) + const session = await this.deps.host.readGateFacts(sessionId) const decision = decideStructuredSessionPointerDelivery({ session }) if (!decision.deliver) { this.retain(mailboxHandle, sessionId, decision.retain, reservedTypes) 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 c530a1e84ab..ba28ee43174 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -38,7 +38,7 @@ describe('structured mailbox pointer host', () => { hostRef.current = null }) - it('reads the gate facts from the FULL timeline, never a bounded tail', () => { + it('reads the gate facts from the FULL timeline, never a bounded tail', async () => { // The defect this pins: a running turn is announced by ONE lifecycle item, and settlement // tombstones it rather than rewriting it. A long tool-calling turn pushes that item arbitrarily // far from the tail, so any page-sized read reports a busy worker as idle — and the pointer is @@ -46,22 +46,22 @@ describe('structured mailbox pointer host', () => { // 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 }) } - expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ + expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ turnRunning: true, awaitingHuman: false }) }) - it('answers null rather than idle when the session cannot be read', () => { + it('answers null rather than idle when the session cannot be read', async () => { // Null retains the pointer; `{turnRunning:false}` would deliver a nudge into a session this // runtime cannot see at all. - expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() + expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() hostRef.current = { journalSnapshot: () => { throw new Error('agent_session_ownership_unknown') } } - expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() + expect(await createStructuredMailboxPointerHost().readGateFacts('s1')).toBeNull() }) it('reports an unattached host rather than a rejection when nothing can be sent', async () => { diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 907a7bcc097..d53dc04600e 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -39,15 +39,16 @@ export function structuredSessionPointerCallerKey(sessionId: string): string { * idle-with-history is the normal steady state of a working agent. Shared so the pointer lane and * group addressing cannot disagree about it. */ -export function readStructuredSessionGateFacts( +export async function readStructuredSessionGateFacts( sessionId: string -): StructuredSessionGateFacts | null { +): Promise { const host = getStructuredAgentSessionHost() if (!host) { return null } try { - return structuredSessionGateFacts(host.journalSnapshot(sessionId).items) + // Opens a conversation the idle sweep closed; that starts no agent. + return structuredSessionGateFacts((await host.journalSnapshot(sessionId)).items) } 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) { 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 31ee011193a..2f7b0a49eaf 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -50,6 +50,7 @@ function installHost(options: { items?: AgentJournalRenderItem[] lease?: { runtimeKind: string; claimStatus: string } hasSession?: boolean + tabListed?: boolean }): void { const lease = options.lease ?? { runtimeKind: 'native', claimStatus: 'live' } hostRef.current = { @@ -65,21 +66,45 @@ function installHost(options: { } }, hasSession: () => options.hasSession ?? true, - journalSnapshot: () => ({ items: options.items ?? [idleTurn()] }) + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: options.tabListed ? [SESSION_ID] : [] + }), + journalSnapshot: async () => ({ items: options.items ?? [idleTurn()] }) } } +/** The durable worker-terminal rows group addressing enumerates. */ +const rows: { + terminal_handle: string + pane_key: string + process_incarnation: string + worktree_id: string + host_scope: string +}[] = [] + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: group addressing reads only this one query off the database. +const workerDb = { listWorkerTerminalResourcesByIncarnationPrefix: () => rows } as never + function registerWorker(worktreeId = 'wt_1'): string { const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_ID) structuredWorkerIdentities.register({ handle, sessionId: SESSION_ID, agent: 'codex', - paneKey: mintStructuredWorkerPaneKey(SESSION_ID), + paneKey, processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), worktreeId, hostScope: { kind: 'local', hostId: 'local' } }) + rows.push({ + terminal_handle: handle, + pane_key: paneKey, + process_incarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktree_id: worktreeId, + host_scope: JSON.stringify({ kind: 'local', hostId: 'local' }) + }) return handle } @@ -88,22 +113,28 @@ const PTY_TERMINAL = { handle: 'term_a', worktreeId: 'wt_1', agentIdentity: 'cla describe('group addressing and structured workers', () => { beforeEach(() => { structuredWorkerIdentities.clear() + rows.length = 0 hostRef.current = null }) it('enumerates a live structured worker as a candidate', () => { const handle = registerWorker() installHost({}) - expect(listAddressableStructuredWorkers()).toEqual([ + expect(listAddressableStructuredWorkers(workerDb)).toEqual([ { handle, worktreeId: 'wt_1', agentIdentity: 'codex' } ]) }) - it('leaves out a worker whose session is not proven live', () => { - // Addressing a settled worker would store mail no lane will ever deliver. - registerWorker() - installHost({ lease: { runtimeKind: 'native', claimStatus: 'live' }, hasSession: false }) - expect(listAddressableStructuredWorkers()).toEqual([]) + it('keeps a worker at rest and leaves out a retired one', () => { + // At rest: its agent was stopped while idle, its chat tab still lists it, and mail starts it. + const handle = registerWorker() + installHost({ lease: { runtimeKind: 'native', claimStatus: 'released' }, tabListed: true }) + expect(listAddressableStructuredWorkers(workerDb)).toEqual([ + { handle, worktreeId: 'wt_1', agentIdentity: 'codex' } + ]) + // Retired: released with its tab gone. Addressing it would store mail no lane delivers. + installHost({ lease: { runtimeKind: 'native', claimStatus: 'released' }, tabListed: false }) + expect(listAddressableStructuredWorkers(workerDb)).toEqual([]) }) it('reaches a structured worker through @all', () => { @@ -112,14 +143,14 @@ describe('group addressing and structured workers', () => { // machinery never ran and the sender got exit 0 with a receipt naming only who did resolve. const handle = registerWorker() installHost({}) - const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers()] + const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers(workerDb)] expect(resolveGroupAddress('@all', 'term_sender', recipients, () => 'idle')).toContain(handle) }) it('reaches a structured worker through @worktree: and @codex, but not @claude', () => { const handle = registerWorker('wt_2') installHost({}) - const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers()] + const recipients = [PTY_TERMINAL, ...listAddressableStructuredWorkers(workerDb)] expect(resolveGroupAddress('@worktree:wt_2', 'term_sender', recipients, () => 'idle')).toEqual([ handle ]) @@ -129,20 +160,20 @@ describe('group addressing and structured workers', () => { ]) }) - it('reads @idle status off the FULL timeline, never a bounded tail', () => { + it('reads @idle status off the FULL timeline, never a bounded tail', async () => { // The same trap that already cost this branch once: settlement tombstones the lifecycle item // rather than rewriting it, so a long tool-calling turn pushes it arbitrarily far from the // tail and any page-sized read reports a BUSY worker as idle — then `@idle` broadcasts into a // running turn, which Codex refuses outright and Claude queues behind. registerWorker() installHost({ items: [runningTurn(), ...transcript(500)] }) - expect(structuredWorkerAgentStatus(SESSION_ID)).toBe('working') + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBe('working') }) - it('answers idle only when no turn is running and no human is awaited', () => { + it('answers idle only when no turn is running and no human is awaited', async () => { registerWorker() installHost({ items: [idleTurn()] }) - expect(structuredWorkerAgentStatus(SESSION_ID)).toBe('idle') + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBe('idle') installHost({ items: [ { @@ -156,26 +187,27 @@ describe('group addressing and structured workers', () => { } as unknown as AgentJournalRenderItem ] }) - expect(structuredWorkerAgentStatus(SESSION_ID)).toBe('attention') + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBe('attention') }) - it('answers null rather than idle when the session cannot be read', () => { + it('answers null rather than idle when the session cannot be read', async () => { // Unknown must never read as idle, or `@idle` wakes a worker mid-turn. hostRef.current = null - expect(structuredWorkerAgentStatus(SESSION_ID)).toBeNull() + expect(await structuredWorkerAgentStatus(SESSION_ID)).toBeNull() }) }) describe('sendGroupMessage actually composes structured workers in', () => { beforeEach(() => { structuredWorkerIdentities.clear() + rows.length = 0 hostRef.current = null }) /** * Drives the real `sendGroupMessage`, not `resolveGroupAddress`. * - * The suite above hand-composed `[PTY_TERMINAL, ...listAddressableStructuredWorkers()]` itself, + * The suite above hand-composed `[PTY_TERMINAL, ...listAddressableStructuredWorkers(workerDb)]` itself, * so deleting the composition at the call site left it green — the exact regression the fix * describes could come straight back. This test owns that seam. */ @@ -189,6 +221,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { getCurrentRunForCoordinator: () => undefined, getActiveDispatchMailboxOwners: () => [], getRunMailboxOwnerIdsForHandle: () => [], + listWorkerTerminalResourcesByIncarnationPrefix: () => rows, insertMessages: (rows: { to: string }[]) => { inserted.push(...rows) return rows.map((row, index) => ({ id: `m${index}`, to_handle: row.to, type: 'status' })) @@ -198,7 +231,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { // No PTY terminals at all: if the call site does not compose structured workers in, the // group resolves empty and this throws instead of delivering. listTerminals: async () => ({ terminals: [] }), - getAgentStatusForHandle: () => 'idle', + getAgentStatusForHandle: async () => 'idle', getLiveTerminalPaneKey: () => structuredWorkerIdentities.get(handle)!.paneKey, notifyMessageArrived: () => {} } @@ -232,6 +265,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { getActiveDispatchForIdentity: () => ({ run_id: 'run_1' }), getActiveDispatchMailboxOwners: () => [], getRunMailboxOwnerIdsForHandle: () => [], + listWorkerTerminalResourcesByIncarnationPrefix: () => rows, listWorkerTerminalResources: () => [ { dispatchId: 'ctx_structured', @@ -259,7 +293,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { listTerminals: async () => ({ terminals: [{ handle: 'term_claude', worktreeId: 'wt_1', agentIdentity: 'claude' }] }), - getAgentStatusForHandle: () => 'idle', + getAgentStatusForHandle: async () => 'idle', getLiveTerminalPaneKey: () => null, getOrchestrationDb: () => db, notifyMessageArrived: () => {} diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index 168bd870118..4115f168552 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -16,8 +16,13 @@ */ import type { TuiAgent } from '../../../shared/tui-agent' -import { observeStructuredWorker, structuredWorkerAgent } from '../structured-worker-authority' -import { structuredWorkerIdentities } from '../structured-worker-identity' +import { structuredWorkerAgent } from '../structured-worker-authority' +import { structuredWorkerAddressable } from '../structured-worker-custody' +import { + STRUCTURED_WORKER_INCARNATION_PREFIX, + structuredWorkerIdentityFromRow +} from '../structured-worker-identity' +import type { OrchestrationDb } from './db' import { readStructuredSessionGateFacts } from './structured-mailbox-pointer-host' /** The only facts group addressing reads off a recipient. */ @@ -29,15 +34,29 @@ export type OrchestrationAddressableAgent = { } /** - * Live structured workers of this runtime, as group-address candidates. + * Structured workers this runtime owns, as group-address candidates. * - * Liveness-gated on the same observation the rest of the structured surface uses: a settled or - * handed-off worker is not a recipient, and addressing one would store mail no lane will deliver. + * Read from the durable worker-terminal rows, which outlive a settled dispatch and a restart, and + * gated on ownership and on the orchestration not having released it — the same answer direct mail + * routes on — never on liveness: a worker at rest is a recipient, and the mail starts it. A retired + * or released one is not. */ -export function listAddressableStructuredWorkers(): OrchestrationAddressableAgent[] { - return structuredWorkerIdentities - .list() - .filter((identity) => observeStructuredWorker(identity).status === 'live') +export function listAddressableStructuredWorkers( + db: OrchestrationDb | null +): OrchestrationAddressableAgent[] { + const seen = new Set() + return ( + db?.listWorkerTerminalResourcesByIncarnationPrefix(STRUCTURED_WORKER_INCARNATION_PREFIX) ?? [] + ) + .flatMap((row) => { + const identity = structuredWorkerIdentityFromRow(row) + // Newest row first: a session is one recipient, under its latest handle. + if (!identity || seen.has(identity.sessionId)) { + return [] + } + seen.add(identity.sessionId) + return structuredWorkerAddressable(db, identity.sessionId, row) === true ? [identity] : [] + }) .map((identity) => ({ handle: identity.handle, worktreeId: identity.worktreeId, @@ -52,8 +71,8 @@ export function listAddressableStructuredWorkers(): OrchestrationAddressableAgen * would wake a worker mid-turn — which Codex coalesces into the running turn and Claude queues * behind it. */ -export function structuredWorkerAgentStatus(sessionId: string): string | null { - const facts = readStructuredSessionGateFacts(sessionId) +export async function structuredWorkerAgentStatus(sessionId: string): Promise { + const facts = await readStructuredSessionGateFacts(sessionId) if (!facts) { return null } diff --git a/src/main/runtime/orchestration/structured-worker-journal-page.ts b/src/main/runtime/orchestration/structured-worker-journal-page.ts index 35676aaf52c..8d260339fa6 100644 --- a/src/main/runtime/orchestration/structured-worker-journal-page.ts +++ b/src/main/runtime/orchestration/structured-worker-journal-page.ts @@ -16,14 +16,17 @@ export type StructuredJournalPage = { hasOlder: boolean } -/** The newest page of a session's journal, or null when this runtime cannot read it. */ -export function readStructuredJournalPage(sessionId: string): StructuredJournalPage | null { +/** The newest page of a session's journal, or null when this runtime cannot read it. A closed + * conversation is opened for the read; that starts no agent. */ +export async function readStructuredJournalPage( + sessionId: string +): Promise { const host = getStructuredAgentSessionHost() if (!host) { return null } try { - const result = host.history({ + const result = await host.history({ sessionId, direction: 'tail', limit: STRUCTURED_JOURNAL_PAGE_LIMIT diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts index d7e894cf54c..ddae515a7f2 100644 --- a/src/main/runtime/orchestration/worker-output-archive.ts +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -91,7 +91,7 @@ export async function captureWorkerOutputArchive(args: { structuredWorker?: StructuredWorkerIdentity | null }): Promise { if (args.structuredWorker) { - const content = captureStructuredWorkerArchive( + const content = await captureStructuredWorkerArchive( args.structuredWorker, structuredWorkerAgent(args.structuredWorker) ) diff --git a/src/main/runtime/orchestration/worker-terminal-process-liveness.test.ts b/src/main/runtime/orchestration/worker-terminal-process-liveness.test.ts new file mode 100644 index 00000000000..e2bc2cc7d63 --- /dev/null +++ b/src/main/runtime/orchestration/worker-terminal-process-liveness.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { matchesProcessIncarnation } from './worker-terminal-process-liveness' + +describe('matchesProcessIncarnation', () => { + it.each([ + { + name: 'an exact ptyId:incarnation match', + ptyId: 'pty-1', + incarnationId: 'incarnation-1', + processIncarnation: 'pty-1:incarnation-1', + expected: true + }, + { + name: 'a Windows repo::C:\\path@@1 ptyId whose incarnation matches', + ptyId: 'repo::C:\\path@@1', + incarnationId: 'incarnation-win', + processIncarnation: 'repo::C:\\path@@1:incarnation-win', + expected: true + }, + { + name: 'a colon-bearing relay incarnationId that a lastIndexOf split would mangle', + ptyId: 'relay-pty', + incarnationId: 'relay:conn-3:incarnation-9', + processIncarnation: 'relay-pty:relay:conn-3:incarnation-9', + expected: true + }, + { + name: 'a whitespace-dirty incarnationId (lost contact is never a match)', + ptyId: 'pty-1', + incarnationId: ' incarnation-1', + processIncarnation: 'pty-1: incarnation-1', + expected: false + }, + { + name: 'an empty-string incarnationId', + ptyId: 'pty-1', + incarnationId: '', + processIncarnation: 'pty-1:', + expected: false + }, + { + name: 'an absent (null) incarnationId', + ptyId: 'pty-1', + incarnationId: null, + processIncarnation: 'pty-1:incarnation-1', + expected: false + }, + { + name: 'an absent (undefined) incarnationId', + ptyId: 'pty-1', + incarnationId: undefined, + processIncarnation: 'pty-1:incarnation-1', + expected: false + }, + { + name: 'a prefix-decoy ptyId (@@1) against a longer live pty (@@10)', + ptyId: 'repo::C:\\path@@1', + incarnationId: 'inc-1', + processIncarnation: 'repo::C:\\path@@10:inc-1', + expected: false + } + ])('returns $expected for $name', ({ ptyId, incarnationId, processIncarnation, expected }) => { + expect(matchesProcessIncarnation(ptyId, incarnationId, processIncarnation)).toBe(expected) + }) + + it('rejects a partial prefix that is not colon-delimited', () => { + // 'pty-10' is not the pty 'pty-1'; the `${ptyId}:` fence keeps them distinct. + expect(matchesProcessIncarnation('pty-1', 'inc', 'pty-10:inc')).toBe(false) + }) +}) diff --git a/src/main/runtime/orchestration/worker-terminal-process-liveness.ts b/src/main/runtime/orchestration/worker-terminal-process-liveness.ts index 72c5ce07666..4e652d6286a 100644 --- a/src/main/runtime/orchestration/worker-terminal-process-liveness.ts +++ b/src/main/runtime/orchestration/worker-terminal-process-liveness.ts @@ -5,6 +5,24 @@ import type { PtyProcessInfo } from '../../providers/pty-process-info' export type { WorkerTerminalHostScope } from '../../../shared/worker-terminal-host-scope' export { parseWorkerTerminalHostScope } from '../../../shared/worker-terminal-host-scope' +/** + * Does `processIncarnation` name exactly this pty's live incarnation? Requires exact + * `${ptyId}:${incarnationId}` equality, so it is immune to colons on either side (relay/SSH + * ptyIds, colon-bearing relay incarnationIds). A pty with no (or a whitespace-dirty) + * incarnationId can never match — the exact-incarnation fence stays intact. + */ +export function matchesProcessIncarnation( + ptyId: string, + incarnationId: string | null | undefined, + processIncarnation: string +): boolean { + if (!incarnationId || incarnationId !== incarnationId.trim()) { + return false + } + return `${ptyId}:${incarnationId}` === processIncarnation +} + +/** Classify a recorded incarnation against live sessions: live on exact match, unverifiable when a candidate pty has a dirty or absent incarnationId (lost contact is never a death certificate), else exited. */ export function classifyWorkerTerminalProcessIncarnation( processIncarnation: string, sessions: readonly PtyProcessInfo[] @@ -13,13 +31,9 @@ export function classifyWorkerTerminalProcessIncarnation( processIncarnation.startsWith(`${session.id}:`) ) if ( - possibleMatches.some((session) => { - const incarnationId = session.incarnationId - if (!incarnationId || incarnationId !== incarnationId.trim()) { - return false - } - return `${session.id}:${incarnationId}` === processIncarnation - }) + possibleMatches.some((session) => + matchesProcessIncarnation(session.id, session.incarnationId, processIncarnation) + ) ) { return 'live' } diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index c843fe1cfa7..802b724a2aa 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { MAX_CODEX_SUBAGENTS_PER_GROUP } from '../../codex/codex-structured-journal-limits' +import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' import { boundWorkerTranscriptMessages, + boundWorkerTranscriptTail, redactWorkerTerminalLines } from './worker-transcript-payload' @@ -177,6 +179,28 @@ describe('worker transcript wire bounds', () => { expect(result).toMatchObject({ limited: false, warnings: [] }) }) + it("serves a structured worker's journal rows without their list position", () => { + const [message] = projectStructuredItemsToNativeChat([ + { + itemId: 'reply', + revision: 1, + sequence: 7, + sequenceIndex: 1, + observedAt: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'done' }] } + } + ]) + // Anti-vacuous: the projection itself does position the row. + expect(message?.journalPosition).toEqual({ sequence: 7, index: 1 }) + for (const served of [ + boundWorkerTranscriptMessages([message!]).messages, + boundWorkerTranscriptTail([message!], 262_144).messages + ]) { + expect(served).toHaveLength(1) + expect(served[0]).not.toHaveProperty('journalPosition') + } + }) + it('keeps two roster ids sharing a 512-char prefix distinct', () => { // The id is the roster key: a plain prefix clip would merge the two children. const head = 'a'.repeat(512) diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index b4365e259c2..684dfc771e2 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -108,8 +108,10 @@ function boundMessage( if (blocks.length < message.blocks.length) { 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. + const { journalPosition: _journalPosition, ...served } = message return { - ...message, + ...served, id: boundIdentifier(message.id, transcriptPath, state), ...(message.turnId ? { turnId: boundIdentifier(message.turnId, transcriptPath, state) } : {}), blocks: blocks.map((block) => boundBlock(block, state)) diff --git a/src/main/runtime/pty-shell-ownership-mirror.ts b/src/main/runtime/pty-shell-ownership-mirror.ts index 5c54465be75..45a7d8e8422 100644 --- a/src/main/runtime/pty-shell-ownership-mirror.ts +++ b/src/main/runtime/pty-shell-ownership-mirror.ts @@ -42,6 +42,11 @@ export class PtyShellOwnershipMirror { } } + /** Reset Terminal: the ground for this mirror's view of mode ownership, already scanned. */ + groundInputModes(): string { + return this.scanner.groundProcessBoundary() + } + get owner(): TerminalOwner | undefined { return this.scanner.owner } diff --git a/src/main/runtime/qoder-terminal-readiness.ts b/src/main/runtime/qoder-terminal-readiness.ts new file mode 100644 index 00000000000..781da3341c0 --- /dev/null +++ b/src/main/runtime/qoder-terminal-readiness.ts @@ -0,0 +1,11 @@ +// Captured Qoder 1.1.64 paints its idle OSC title even while the trust menu owns input. +export function isQoderComposerReady(screenLines: readonly string[] | null): boolean { + if (!screenLines) { + return false + } + const screen = screenLines.join('\n').toLowerCase() + return ( + screen.includes('type your message or @path/to/file') && + !screen.includes("don't trust and exit") + ) +} diff --git a/src/main/runtime/qoder-transcripts.test.ts b/src/main/runtime/qoder-transcripts.test.ts new file mode 100644 index 00000000000..89c5881382a --- /dev/null +++ b/src/main/runtime/qoder-transcripts.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane } from './agent-transcript-pane-test-harness' +import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import { getAgentLabel, normalizeTerminalTitle } from '../../shared/agent-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') } +})) + +describe('captured Qoder 1.1.64 startup', () => { + it.each(['qoder-trust-dialog', 'qoder-no-account', 'qoder-ready'])( + 'preserves Qoder identity in %s', + async (fixture) => { + const data = readFileSync(join(__dirname, '__fixtures__', `${fixture}.txt`), 'utf8') + // The recorder's shutdown clears the OSC title; inspect the live capture before that reset. + const title = extractLastOscTitle( + data.replaceAll(`${String.fromCharCode(27)}]0;${String.fromCharCode(7)}`, '') + ) + expect(title).toContain(' | Ready') + expect(getAgentLabel(normalizeTerminalTitle(title ?? ''))).toBe('Qoder CLI') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: title ?? '', + foregroundProcess: 'qodercli-1.1.64', + launchAgent: 'qoder', + data, + size: { cols: 100, rows: 32 } + }) + const shown = await runtime.showTerminal(handle) + expect(shown.agentIdentity).toBe('qoder') + if (fixture === 'qoder-trust-dialog') { + const readiness = await runtime + .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 600 }) + .catch(() => null) + expect(readiness?.satisfied ?? false).toBe(false) + } + if (fixture === 'qoder-ready') { + const readiness = await runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 1500 + }) + expect(readiness.satisfied).toBe(true) + } + } + ) +}) diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 02ae6a335f2..624557c1f7f 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -15,6 +15,11 @@ import { nestedWorkerDepthExceededMessage } from '../../../shared/nested-worker-depth' import { OrchestrationError } from '../orchestration/orchestration-error' +import { + AgentSessionRefusalError, + agentSessionRefusalError, + refuseUnclassified +} from '../../../shared/agent-session-wire-refusals' class LineageError extends Error { code = 'LINEAGE_PARENT_NOT_FOUND' @@ -297,3 +302,63 @@ describe('structured worker dispatch preamble errors', () => { }) }) }) + +describe('thrown agent-session refusals', () => { + const meta = { runtimeId: 'runtime-1' } + + // Released clients classify a thrown refusal by its wire code and message; both must read + // exactly as the bare `Error(code)` this replaced. + it.each([ + [ + 'agent_session_ownership_unknown', + 'agent_session_ownership_unknown', + agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'noLiveOwner' }) + ], + [ + 'structured_agent_session_unsupported', + 'runtime_error', + agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostDisabled' }) + ], + [ + 'agent_session_checkpoint_stale', + 'agent_session_checkpoint_stale', + agentSessionRefusalError('agent_session_checkpoint_stale', { + reason: 'fenceStale', + currentFence: 4 + }) + ] + ] as const)( + 'keeps %s on the wire as it was, and adds its details in data', + (code, wire, error) => { + const before = mapRuntimeError('req_1', meta, new Error(code)) + const after = mapRuntimeError('req_1', meta, error) + expect(after.error.code).toBe(before.error.code) + expect(after.error.code).toBe(wire) + expect(after.error.message).toBe(before.error.message) + expect(after.error.message).toBe(code) + expect(after.error.data).toEqual({ refusal: { code, details: error.refusal.details } }) + expect(error.refusal.details?.reason).toBeDefined() + } + ) + + it('carries no details in data when the refusal named none', () => { + const response = mapRuntimeError( + 'req_1', + meta, + new AgentSessionRefusalError( + refuseUnclassified('agent_session_conflict', 'Another process claims this session.') + ) + ) + expect(response.error).toEqual({ + code: 'agent_session_conflict', + message: 'agent_session_conflict', + data: { refusal: { code: 'agent_session_conflict' } } + }) + }) + + it('exposes no code property another passthrough could claim', () => { + expect( + 'code' in agentSessionRefusalError('agent_session_conflict', { reason: 'claimConflicted' }) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index ec7f7a0559d..6d122c02d45 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -2,6 +2,11 @@ // runtime/browser error allowlists define the contract the CLI relies on to // format human-facing messages. Centralizing this mapping keeps the allowlist // auditable in one place instead of spread across per-method branches. +import { + agentSessionRefusalReference, + isAgentSessionRefusalError, + type AgentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES } from '../../../shared/orchestration-session-caller-codes' import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery' @@ -160,6 +165,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { const message = error instanceof Error ? error.message : String(error) + if (isAgentSessionRefusalError(error)) { + return agentSessionRefusalErrorResponse(id, meta, error) + } if ( error instanceof Error && 'code' in error && @@ -230,6 +238,27 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow return errorResponse(id, meta, 'runtime_error', message) } +/** + * A thrown agent-session refusal, mapped before any `'code' in error` passthrough so no other + * subsystem's code set can claim it. Wire code and message are exactly what the bare `Error(code)` + * it replaced produced — released clients classify both — and the refusal's details ride only in + * `data`, which they ignore. + */ +function agentSessionRefusalErrorResponse( + id: string, + meta: RpcEnvelopeMeta, + error: AgentSessionRefusalError +): RpcFailure { + const { code } = error.refusal + return errorResponse( + id, + meta, + RUNTIME_PASSTHROUGH_CODES.has(code) ? code : 'runtime_error', + code, + { refusal: agentSessionRefusalReference(error.refusal) } + ) +} + export const computerErrorData = computerUseErrorRecoveryData // Why: browser errors carry a structured .code property (BrowserError from 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 3d6981ed4a1..336cd4c212b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -585,7 +585,8 @@ describe('the inner attach reserves under its own id', () => { }) expect(forwarded).toEqual({ decision: 'refused', - code: 'agent_session_operation_conflict' + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } }) const derived = deriveAgentLaunchChildOperationId(OPERATION_ID) diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 0037d6fc35e..11762137ba2 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -63,9 +63,9 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { // Settlement can fail after the journal append. Re-read the authoritative row before asking // the caller to resend, otherwise a retry creates a duplicate turn. try { - const committed = args.host - .journalSnapshot(args.sessionId) - .submissions.find((submission) => submission.clientMessageId === clientMessageId) + const committed = (await args.host.journalSnapshot(args.sessionId)).submissions.find( + (submission) => submission.clientMessageId === clientMessageId + ) if (committed) { return clientMessageId } diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 49b0bd2bf40..07dcdb53186 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -4,8 +4,8 @@ * Both halves are deliberately the plain, user-facing forms: a structured session created for the * worktree exactly as `agentSession.create` creates one, and a terminal agent created exactly as a * new agent tab is. Orchestration's own factories are NOT reusable here — a worker's session - * carries a dispatch hold, a mailbox and a background tab that a launch the user asked for must - * not take — which is why the executor injects this rather than branching. + * carries a redrive subscription, a mailbox and a background tab that a launch the user asked for + * must not take — which is why the executor injects this rather than branching. * * Delivering the launch text is here for the same reason: it is the wire-shaped half. Each surface * takes it differently — a structured session commits it to a transcript, a terminal agent takes it diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts index 41081daeb99..ac66912e747 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts @@ -66,6 +66,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: { return false } const sent = await args.runtime.sendTerminalAgentPrompt(args.handle, args.text, { + inputKind: 'launch', // Paired: together these take the queued path, which settles an unobserved turn start into // an `input_accepted` receipt rather than raising it. Without the id the write is verified // strictly and a slow first turn throws. diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts index 983bd20ad95..cf607524674 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts @@ -7,13 +7,12 @@ const released: string[] = [] vi.mock('./orchestration-structured-worker-session', () => ({ releaseStructuredWorkerSession: (dispatchId: string) => released.push(dispatchId), createStructuredWorkerSession: vi.fn(), - sendStructuredWorkerPreamble: vi.fn(), - structuredWorkerHoldId: (dispatchId: string) => `orchestration:dispatch:${dispatchId}` + sendStructuredWorkerPreamble: vi.fn() })) const harness = createOrchestrationRpcHarness() -describe('workerAbandon settles the structured hold', () => { +describe('workerAbandon settles the structured worker session', () => { let state: OrchestrationRpcState beforeEach(() => { @@ -37,10 +36,10 @@ describe('workerAbandon settles the structured hold', () => { return started.dispatch.id } - it('releases the hold when the dispatch actually settles', async () => { + it('releases the worker session when the dispatch actually settles', async () => { const dispatchId = await startedDispatch() - // Without this, the resume-capable hold outlives settlement: the provider child can never be - // evicted and host crash recovery keeps respawning an abandoned worker. + // Without this, the redrive subscription outlives settlement and keeps nudging a session no + // dispatch owns. await harness.call('orchestration.workerAbandon', { dispatch: dispatchId }, state.ctx) expect(released).toEqual([dispatchId]) }) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts index a0d0426509b..b669233c31f 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts @@ -47,6 +47,7 @@ function installHost(options: { close?: () => Promise setSessionTabVisibility?: () => Promise historyThrows?: boolean + tabListed?: boolean }) { const record = options.record === undefined @@ -63,6 +64,10 @@ function installHost(options: { let closed = false hostRef.current = { deps: { store: { getRecord: () => record } }, + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: options.tabListed ? [IDENTITY.sessionId] : [] + }), hasSession: () => (closed ? false : (options.hasSession ?? true)), setSessionTabVisibility: options.setSessionTabVisibility ?? (async () => {}), close: @@ -84,7 +89,7 @@ describe('structured worker observation', () => { hostRef.current = null }) - it('is unverifiable, never exited, when the host is not installed', () => { + it('is unverifiable, never exited, when the host is not installed', async () => { // Not being able to look is not a death certificate. expect(observeStructuredWorker(IDENTITY)).toEqual({ status: 'unverifiable', @@ -92,12 +97,12 @@ describe('structured worker observation', () => { }) }) - it('is live when the host holds the session under a live native lease', () => { + it('is live when the host holds the session under a live native lease', async () => { installHost({}) expect(observeStructuredWorker(IDENTITY).status).toBe('live') }) - it('is exited only on a released lease with death evidence', () => { + it('is exited only on a released lease with death evidence', async () => { installHost({ claimStatus: 'released', deathEvidence: { kind: 'exit-observed', detail: 'x', observedAt: 1 } @@ -105,7 +110,7 @@ describe('structured worker observation', () => { expect(observeStructuredWorker(IDENTITY).status).toBe('exited') }) - it('is unverifiable when a terminal an older build recorded holds the lease', () => { + it('is unverifiable when a terminal an older build recorded holds the lease', async () => { installHost({ claimStatus: 'conflicted' }) expect(observeStructuredWorker(IDENTITY).status).toBe('unverifiable') }) @@ -127,22 +132,30 @@ describe('structured worker stop', () => { }) }) - it.each([ - { hasSession: false }, - { claimStatus: 'conflicted' }, - { claimStatus: 'released' }, - { record: null } - ])('retains without positive exit evidence: %j', async (options) => { - installHost({ ...options, close: async () => {} }) - const retireStructuredAgentSessionTabFromSnapshot = vi.fn() - const result = await stopStructuredWorker(IDENTITY, 'd1', { - forgetStructuredSessionMail: vi.fn(), - retireStructuredAgentSessionTabFromSnapshot + it('settles a released lease whose stop was unproven: nothing is left running to close', async () => { + // A release that could not prove its stop sent no signal and left the verdict `unverifiable`; + // closing the chat is the user's action, so that bookkeeping does not refuse it. + installHost({ claimStatus: 'released', close: async () => {} }) + await expect(stopStructuredWorker(IDENTITY, 'd1')).resolves.toEqual({ + stopped: true, + closeAttempted: true }) - expect(result).toMatchObject({ stopped: false, closeAttempted: true }) - expect(retireStructuredAgentSessionTabFromSnapshot).not.toHaveBeenCalled() }) + it.each([{ hasSession: false }, { claimStatus: 'conflicted' }, { record: null }])( + 'retains without positive exit evidence: %j', + async (options) => { + installHost({ ...options, close: async () => {} }) + const retireStructuredAgentSessionTabFromSnapshot = vi.fn() + const result = await stopStructuredWorker(IDENTITY, 'd1', { + forgetStructuredSessionMail: vi.fn(), + retireStructuredAgentSessionTabFromSnapshot + }) + expect(result).toMatchObject({ stopped: false, closeAttempted: true }) + expect(retireStructuredAgentSessionTabFromSnapshot).not.toHaveBeenCalled() + } + ) + it('retains when the close throws, and admits the close was issued', async () => { installHost({ close: async () => { @@ -191,9 +204,9 @@ describe('structured worker output', () => { hostRef.current = null }) - it('round-trips the journal through the archive and back out of a released read', () => { + it('round-trips the journal through the archive and back out of a released read', async () => { installHost({}) - const live = readStructuredWorkerJournal({ + const live = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -201,7 +214,7 @@ describe('structured worker output', () => { agent: 'claude' }) expect(live.source).toBe('transcript') - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') hostRef.current = null const archived = readArchivedStructuredJournal({ dispatchId: 'd1', @@ -219,7 +232,7 @@ describe('structured worker output', () => { expect(archived.sourceIdentity).not.toBe(live.sourceIdentity) }) - it('redacts dispatch capabilities from the archived journal', () => { + it('redacts dispatch capabilities from the archived journal', async () => { installHost({ items: [ { @@ -233,13 +246,13 @@ describe('structured worker output', () => { } as unknown as AgentJournalRenderItem ] }) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') expect(JSON.stringify(archive)).not.toContain('dcap_aaa') expect(JSON.stringify(archive)).toContain('[dispatch capability redacted]') }) - it('refuses to read a session the host no longer holds', () => { - expect(() => + it('refuses to read a session the host no longer holds', async () => { + await expect( readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', @@ -247,15 +260,15 @@ describe('structured worker output', () => { liveness: 'live', agent: 'claude' }) - ).toThrow(/not attached/) + ).rejects.toThrow(/not attached/) }) - it('reports an unverifiable worker as unknown, never as running', () => { + it('reports an unverifiable worker as unknown, never as running', async () => { // The `could not look, therefore it is alive` inversion. After a restart the runtime observes // `unverifiable` — no attached provider child in this generation — while the journal is still // readable, and a coordinator reading `running` waits on a worker that may already be gone. installHost({}) - const read = readStructuredWorkerJournal({ + const read = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -266,9 +279,9 @@ describe('structured worker output', () => { expect(read.status.liveness).toBe('unverifiable') }) - it('carries each proven verdict through unchanged', () => { + it('carries each proven verdict through unchanged', async () => { installHost({}) - const live = readStructuredWorkerJournal({ + const live = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -276,7 +289,7 @@ describe('structured worker output', () => { agent: 'claude' }) expect(live.status).toMatchObject({ terminal: 'running', liveness: 'live' }) - const exited = readStructuredWorkerJournal({ + const exited = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'succeeded', @@ -286,9 +299,9 @@ describe('structured worker output', () => { expect(exited.status).toMatchObject({ terminal: 'exited', liveness: 'exited' }) }) - it('states that a settled release is exited', () => { + it('states that a settled release is exited', async () => { installHost({}) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') const archived = readArchivedStructuredJournal({ dispatchId: 'd1', workerState: 'succeeded', @@ -300,12 +313,12 @@ describe('structured worker output', () => { expect(archived.status).toMatchObject({ terminal: 'exited', liveness: 'exited' }) }) - it('never calls an unproven release exited', () => { + it('never calls an unproven release exited', async () => { // The archive is frozen BEFORE the close. `release_unknown` is the state that records a close // that did NOT land, and a coordinator reading `exited` there starts a replacement worker over // the same worktree while the original provider child may still be attached. installHost({}) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') for (const releaseState of ['unknown', 'releasing'] as const) { const archived = readArchivedStructuredJournal({ dispatchId: 'd1', @@ -324,7 +337,7 @@ describe('structured worker output', () => { // `readArchivedWorkerOutput`, and the resource row it already holds is the only thing that // knows whether the close landed. installHost({}) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') const db = { getWorkerTerminalArchive: () => ({ dispatch_id: 'd1', @@ -355,12 +368,12 @@ describe('structured worker output', () => { }) }) - it('refuses a cursor once the tail window has slid past it', () => { + it('refuses a cursor once the tail window has slid past it', async () => { // The cursor is an index into the bounded tail, and `sourceIdentity` was constant for the // worker's life, so a coordinator paging a growing journal resumed at the newest items and // skipped the middle without a word. installHost({ items: ITEMS }) - const first = readStructuredWorkerJournal({ + const first = await readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', workerState: 'ready', @@ -376,7 +389,7 @@ describe('structured worker output', () => { } as unknown as AgentJournalRenderItem ] }) - expect(() => + await expect( readStructuredWorkerJournal({ identity: IDENTITY, dispatchId: 'd1', @@ -385,7 +398,7 @@ describe('structured worker output', () => { agent: 'claude', cursor: first.cursor }) - ).toThrow(/source changed/i) + ).rejects.toThrow(/source changed/i) }) }) @@ -394,7 +407,7 @@ describe('archiving a structured worker whose journal cannot be read', () => { hostRef.current = null }) - it('settles with an empty, warned archive once the session is PROVEN gone', () => { + it('settles with an empty, warned archive once the session is PROVEN gone', async () => { // Closing the worker's chat tab is a routine user action: it evicts the child and detaches the // journal permanently. Throwing archive_failed there wedged release on evidence that could // never arrive, leaving worker-abandon as the only way out. @@ -403,7 +416,7 @@ describe('archiving a structured worker whose journal cannot be read', () => { claimStatus: 'released', deathEvidence: { kind: 'exit-observed', detail: 'surface released', observedAt: 1 } }) - const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + const archive = await captureStructuredWorkerArchive(IDENTITY, 'claude') expect(archive.messages).toEqual([]) expect(archive.processIncarnation).toBe(IDENTITY.processIncarnation) expect(archive.warnings).toContain( @@ -411,12 +424,24 @@ describe('archiving a structured worker whose journal cannot be read', () => { ) }) - it('still retains when the journal is unreadable but nothing proves the child is gone', () => { - installHost({ historyThrows: true }) - expect(() => captureStructuredWorkerArchive(IDENTITY, 'claude')).toThrow(/retained/) + it('retains a worker at rest: released, but its chat tab still lists it', async () => { + // Released is not retired now — the idle sweep releases a quiet worker's lease — so an owned + // worker whose journal cannot be read keeps its evidence for a later archive. + installHost({ + historyThrows: true, + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'idle stop', observedAt: 1 }, + tabListed: true + }) + await expect(captureStructuredWorkerArchive(IDENTITY, 'claude')).rejects.toThrow(/retained/) }) - it('still retains when there is no host to look with', () => { - expect(() => captureStructuredWorkerArchive(IDENTITY, 'claude')).toThrow(/retained/) + it('still retains when the journal is unreadable but nothing proves the child is gone', async () => { + installHost({ historyThrows: true }) + await expect(captureStructuredWorkerArchive(IDENTITY, 'claude')).rejects.toThrow(/retained/) + }) + + it('still retains when there is no host to look with', async () => { + await expect(captureStructuredWorkerArchive(IDENTITY, 'claude')).rejects.toThrow(/retained/) }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts index 13d3ce1dbce..bec15537c49 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts @@ -40,12 +40,13 @@ import { structuredWorkerTerminalState, type StructuredWorkerObservation } from '../../structured-worker-authority' +import { structuredWorkerOwned } from '../../structured-worker-custody' import type { StructuredWorkerIdentity } from '../../structured-worker-identity' import type { WorkerTerminalReleaseState } from '../../orchestration/worker-terminal-ownership' import { releaseStructuredWorkerSession } from './orchestration-structured-worker-session' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' -export { observeStructuredWorker, type StructuredWorkerObservation } +export { observeStructuredWorker, structuredWorkerOwned, type StructuredWorkerObservation } /** The structured worker behind a dispatch, or null when a PTY worker owns it. */ export function resolveStructuredWorkerForDispatch( @@ -83,13 +84,13 @@ export async function stopStructuredWorker( return closeStructuredAgentSessionChild(identity.sessionId, { ...(runtime ? { runtime } : {}), // Between the close and the proof, never after: an unsettled close returns early, and a - // surviving hold keeps the provider child un-evictable for the life of the app. + // surviving redrive subscription keeps nudging a session no dispatch owns. afterClose: () => releaseStructuredWorkerSession(dispatchId, runtime) }) } /** The structured half of `worker-read`, or null when a PTY worker owns the dispatch. */ -export function readStructuredWorkerOutput(args: { +export async function readStructuredWorkerOutput(args: { db: OrchestrationDb dispatchId: string workerState: string @@ -98,7 +99,7 @@ export function readStructuredWorkerOutput(args: { source?: 'auto' | 'transcript' | 'terminal' cursor?: string | number limit?: number -}): OrchestrationWorkerReadTranscriptResult | null { +}): Promise { const identity = resolveStructuredWorkerForDispatch(args.db, args.dispatchId) if (!identity) { return null @@ -123,7 +124,7 @@ export function readStructuredWorkerOutput(args: { } /** Journal page in the shape `worker-read --source transcript` already serves. */ -export function readStructuredWorkerJournal(args: { +export async function readStructuredWorkerJournal(args: { identity: StructuredWorkerIdentity dispatchId: string workerState: string @@ -131,8 +132,8 @@ export function readStructuredWorkerJournal(args: { agent: AgentType cursor?: string | number limit?: number -}): OrchestrationWorkerReadTranscriptResult { - const page = readStructuredJournalPage(args.identity.sessionId) +}): Promise { + const page = await readStructuredJournalPage(args.identity.sessionId) if (!page) { throw new OrchestrationError( 'transcript_required', @@ -213,11 +214,13 @@ function structuredJournalPrefixIdentity(args: { } /** Freezes the journal before the session is closed, so a released worker is still readable. */ -export function captureStructuredWorkerArchive( +export async function captureStructuredWorkerArchive( identity: StructuredWorkerIdentity, agent: AgentType -): WorkerStructuredJournalArchive { - const page = readStructuredJournalPage(identity.sessionId) +): Promise { + // Opens a conversation at rest or one the idle sweep closed, so a resting worker's journal is + // still preserved. + const page = await readStructuredJournalPage(identity.sessionId) if (page) { return buildStructuredJournalArchive({ agent, @@ -232,10 +235,9 @@ export function captureStructuredWorkerArchive( // the worker's chat tab is a routine user action that does exactly that, so throwing there wedges // release on evidence that can never come and leaves `worker-abandon` as the only exit. // - // `exited` is the only verdict that qualifies: it needs a released lease WITH death evidence. - // `unverifiable` — no host installed, a lease handed to a TUI owner — means we could not look, - // and retaining is still right. - if (observeStructuredWorker(identity).status !== 'exited') { + // Only a worker this runtime no longer owns qualifies — released with its chat tab gone. An owned + // one, running or at rest, keeps its journal, and so does one we could not look at. + if (structuredWorkerOwned(identity.sessionId) !== false) { throw new OrchestrationError( 'archive_failed', 'Output could not be preserved for this structured worker; the session was retained.' diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 86568a0435f..0d1f04cb83b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { dispatchWriteFailureReason } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' const hostRef: { current: unknown } = { current: null } const createSpy = vi.fn() @@ -15,37 +15,34 @@ vi.mock('./structured-agent-session-create', () => ({ const { createStructuredWorkerSession, releaseStructuredWorkerSession, - sendStructuredWorkerPreamble, - structuredWorkerHoldId + sendStructuredWorkerPreamble } = await import('./orchestration-structured-worker-session') const { isUnknownWorkerStartOutcome } = await import('./orchestration/worker/worker-topology') const { structuredWorkerIdentities } = await import('../../structured-worker-identity') const { structuredWorkerChildIdentityEnv } = await import('../../structured-worker-child-identity-env') -function installHost() { - const hold = vi.fn(async () => {}) - const release = vi.fn() +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a host stub carrying only the members the worker start reaches. +function installHost(location = { executionHostId: 'local', wslDistro: null as string | null }) { const dispose = vi.fn() + const subscribe = vi.fn(async () => dispose) hostRef.current = { setSessionTabVisibility: async () => {}, close: async () => {}, deps: { store: { getRecord: () => ({ - location: { executionHostId: 'local', wslDistro: null }, + location, lease: { runtimeFence: 2, runtimeKind: 'native', claimStatus: 'live' } }) } }, - hold, - release, - subscribe: () => dispose + subscribe } - return { hold, release, dispose } + return { subscribe, dispose } } -describe('structured worker session hold', () => { +describe('structured worker session', () => { beforeEach(() => { structuredWorkerIdentities.clear() createSpy.mockReset() @@ -55,8 +52,8 @@ describe('structured worker session hold', () => { })) }) - it('takes a resume-capable hold at start and releases it only on settlement', async () => { - const { hold, release, dispose } = installHost() + it('binds only a redrive subscription at start, and settlement drops it', async () => { + const { subscribe, dispose } = installHost() const created = await createStructuredWorkerSession({ runtime: { ensureStructuredAgentSessionHost: async () => {} } as never, worktreeId: 'wt_1', @@ -64,18 +61,19 @@ describe('structured worker session hold', () => { dispatchId: 'd1', onJournalActivity: () => {} }) - // Without the hold, the release clock evicts the provider child 15s after a user closes the - // worker's chat tab, killing an idle worker mid-dispatch. - expect(hold).toHaveBeenCalledWith(created.identity.sessionId, structuredWorkerHoldId('d1')) - expect(release).not.toHaveBeenCalled() + // No hold: while the dispatch is open the idle sweep reads it from the orchestration database. + expect(hostRef.current).not.toHaveProperty('hold') + expect(subscribe).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: created.identity.sessionId }) + ) + expect(dispose).not.toHaveBeenCalled() releaseStructuredWorkerSession('d1') - expect(release).toHaveBeenCalledWith(created.identity.sessionId, structuredWorkerHoldId('d1')) expect(dispose).toHaveBeenCalledTimes(1) expect(structuredWorkerIdentities.get(created.identity.handle)).toBeNull() - // A second settlement is a no-op rather than a second release of the same holder. + // A second settlement is a no-op. releaseStructuredWorkerSession('d1') - expect(release).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(1) }) it('registers the identity BEFORE the session is created, so the child gets the handle', async () => { @@ -101,8 +99,8 @@ describe('structured worker session hold', () => { }) it('forgets the identity and discards the session when the start fails', async () => { - const { hold } = installHost() - hold.mockRejectedValueOnce(new Error('hold refused')) + // A session that resolves outside the local host is not a worker this runtime can own. + installHost({ executionHostId: 'local', wslDistro: 'Ubuntu' }) const closed: string[] = [] ;(hostRef.current as { close: (id: string) => Promise }).close = async (id) => { closed.push(id) @@ -115,7 +113,7 @@ describe('structured worker session hold', () => { dispatchId: 'd_fail', onJournalActivity: () => {} }) - ).rejects.toThrow('hold refused') + ).rejects.toThrow(/local execution host outside WSL/) // Neither a live provider child nor a registry entry may outlive the failed start. expect(closed).toHaveLength(1) expect(structuredWorkerIdentities.getBySessionId(closed[0]!)).toBeNull() @@ -129,7 +127,7 @@ describe('structured worker session hold', () => { } // `commit` answers this after `attach` SUCCEEDED and only the tab publish failed, so the // provider child is live. Reading it as "refused, nothing created" strands that child with no - // hold and no binding, and nothing else in the runtime ever retires it. + // binding, and nothing else in the runtime ever retires it. createSpy.mockImplementation(async () => ({ ok: false, refusal: { @@ -316,13 +314,33 @@ describe('structured worker dispatch preamble', () => { expect(isUnknownWorkerStartOutcome(error, 'dispatch_input')).toBe(false) }) + it('ends the message with one period whether the reason is a sentence or a marker', async () => { + const sentence = await send( + hostWithSubmission({ + dispatchState: 'rejected', + reason: 'The provider stopped before this message was sent.' + }) + ).catch((thrown: unknown) => thrown) + expect(sentence).toMatchObject({ + message: + 'The dispatch preamble was not delivered: The provider stopped before this message was sent.' + }) + const marker = await send( + hostWithSubmission({ dispatchState: 'unknown', reason: 'provider child exited' }) + ).catch((thrown: unknown) => thrown) + expect(marker).toMatchObject({ + message: + 'The dispatch preamble was submitted but not acknowledged (unknown): provider child exited.' + }) + }) + it('reports a refused transport write as undelivered, never as unknown', async () => { // The state a provably-unwritten frame now settles. Nothing reached the provider, // so there is no running turn for a coordinator to go and look at. const error = await send( hostWithSubmission({ dispatchState: 'rejected', - reason: dispatchWriteFailureReason(new Error('broken pipe')) + reason: DISPATCH_REJECTED_WRITE_FAILED }) ).catch((thrown: unknown) => thrown) expect((error as { code?: string }).code).toBe('dispatch_preamble_undelivered') 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 06f657924b1..dad93face82 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -1,13 +1,13 @@ /** - * Starting, holding and retiring a worker that IS a structured agent session. + * Starting and retiring a worker that IS a structured agent session. * * Three things make this different from the PTY worker path, and all three live here: * * - The session is created directly as structured, so readiness is the attach returning ok. There * is no boot-to-idle gap to wait on and no `tui-idle` edge to read. - * - A structured session's provider child is evicted 15s after its last HOLDER leaves, and holds - * come only from bound surfaces. A dispatched worker parked on mail is exactly that state, so - * the dispatch takes its own resume-capable hold and keeps it until the worker settles. + * - Nothing here keeps its agent running. The idle sweep leaves it running while its dispatch is + * open, reading that from the orchestration database; once the dispatch settles the agent rests + * like any chat's, and the next mail starts it. * - The dispatch preamble is a turn, not keystrokes. */ @@ -39,23 +39,16 @@ import { createStructuredAgentSessionForWorktree } from './structured-agent-sess type StructuredWorkerBinding = { sessionId: string handle: string - holderId: string disposeSubscription: () => void } const bindingsByDispatchId = new Map() -export function structuredWorkerHoldId(dispatchId: string): string { - return `orchestration:dispatch:${dispatchId}` -} - /** - * Drops the dispatch's hold, its redrive subscription and its parked mail; the release clock takes - * it from here. + * Drops the dispatch's redrive subscription, its registry entry and its parked mail. * - * EVERY settlement has to reach this — stop, release AND abandon. A surviving hold does not just - * leak: it keeps the provider child un-evictable for the life of the app, and makes host crash - * recovery respawn a child for a worker that was settled long ago. + * EVERY settlement has to reach this — stop, release AND abandon. A surviving subscription keeps + * nudging a session no dispatch owns. */ export function releaseStructuredWorkerSession( dispatchId: string, @@ -69,11 +62,6 @@ export function releaseStructuredWorkerSession( binding.disposeSubscription() structuredWorkerIdentities.forget(binding.handle) runtime?.forgetStructuredSessionMail?.(binding.sessionId) - try { - getStructuredAgentSessionHost()?.release(binding.sessionId, binding.holderId) - } catch (error) { - console.warn('[orchestration] structured worker hold release failed', dispatchId, error) - } } export async function createStructuredWorkerSession(args: { @@ -93,7 +81,7 @@ export async function createStructuredWorkerSession(args: { // resolves to whatever single leaf sits in the worktree — by default the COORDINATOR's pane. // // The scope is provisionally local; the record's own location is asserted local below, and a - // session that resolves anywhere else never reaches a hold. + // session that resolves anywhere else is discarded. const identity = structuredWorkerIdentities.register({ handle: mintStructuredWorkerHandle(), sessionId, @@ -142,13 +130,10 @@ export async function createStructuredWorkerSession(args: { 'A structured worker must run on the local execution host outside WSL.' ) } - const holderId = structuredWorkerHoldId(args.dispatchId) - await host.hold(sessionId, holderId) - const disposeSubscription = subscribeForRedrive(host, sessionId, args.onJournalActivity) + const disposeSubscription = await subscribeForRedrive(host, sessionId, args.onJournalActivity) bindingsByDispatchId.set(args.dispatchId, { sessionId, handle: identity.handle, - holderId, disposeSubscription }) return { identity, host } @@ -168,8 +153,8 @@ export async function createStructuredWorkerSession(args: { * * `ok` is not the test. `commit` answers `agent_session_operation_unknown` when `attach` SUCCEEDED * and only the tab publish failed, and a throw out of the commit half is past `attach` too — the - * pre-commit half never throws, it refuses. Both leave a live provider child that took no hold and - * has no binding, so nothing else in the runtime will ever retire it. Only a DEFINITIVE refusal + * pre-commit half never throws, it refuses. Both leave a session with a published tab and no + * binding, so nothing else in the runtime will ever retire it. Only a DEFINITIVE refusal * proves there is nothing to discard; everything else gets the best-effort close. */ function structuredCreateMayHaveCommitted( @@ -214,6 +199,11 @@ export async function discardStructuredWorkerSession( retireSettledStructuredWorkerTab(sessionId, runtime) } +/** A submission reason as a clause: host sentences end in a period, legacy markers do not. */ +function reasonClause(reason: string | null | undefined): string { + return (reason ?? 'no reason given').replace(/[.\s]+$/, '') +} + /** What a preamble send reads of the host. */ type StructuredWorkerPreambleHost = Pick< StructuredAgentSessionHost, @@ -276,7 +266,9 @@ export async function sendStructuredWorkerPreamble(args: { // pending receipt; only this one lets the caller retry knowing nothing landed. throw new OrchestrationError( 'dispatch_preamble_undelivered', - `The dispatch preamble was not delivered: ${submission.reason ?? 'no reason given'}.` + `The dispatch preamble was not delivered${ + submission.rejection ? ` (${submission.rejection.kind})` : '' + }: ${reasonClause(submission.reason)}.` ) } // Only `accepted` is an acknowledgement — the same rule the mail lane already applies. A thrown @@ -285,7 +277,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}): ${submission.reason ?? 'no reason given'}.` + `The dispatch preamble was submitted but not acknowledged (${submission.dispatchState}): ${reasonClause(submission.reason)}.` ) } @@ -324,17 +316,17 @@ const REDRIVE_MAX_WAIT_MS = 2_000 * idle worker — that is `deliverForHandle`, called when the message is enqueued and untouched * here. This is only the retry for mail already parked because the worker was busy. */ -function subscribeForRedrive( +async function subscribeForRedrive( host: StructuredAgentSessionHost, sessionId: string, onJournalActivity: (sessionId: string) => void -): () => void { +): Promise<() => void> { const coalescer = createKeyedTrailingEdgeCoalescer(onJournalActivity, { flushMs: REDRIVE_FLUSH_MS, maxWaitMs: REDRIVE_MAX_WAIT_MS }) try { - const unsubscribe = host.subscribe({ + const unsubscribe = await host.subscribe({ id: `orchestration:redrive:${sessionId}`, sessionId, emit: (event) => { @@ -344,7 +336,7 @@ function subscribeForRedrive( } }) // Disposal drops the pending timer rather than flushing it: every settlement reaches here, and - // a redrive that fires after the hold is gone would nudge a session no dispatch owns. + // a redrive that fires after it would nudge a session no dispatch owns. return () => { coalescer.dispose() unsubscribe() 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 48ffe07d5d0..199e43fae7b 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 @@ -254,10 +254,14 @@ describe('a worker cannot tell which mode it is running in', () => { source: 'terminal' }) - expect(read).toThrow(/has no terminal output/) + const refusal = await read().then( + () => '', + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ) + expect(refusal).toMatch(/has no terminal output/) // The refusal names a source that works instead of naming the worker's kind. - expect(read).toThrow(/--source auto or --source transcript/) - expect(read).not.toThrow(/structured/i) + expect(refusal).toMatch(/--source auto or --source transcript/) + expect(refusal).not.toMatch(/structured/i) }) it('never claims a structured worker was checked for a human-answerable prompt', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts new file mode 100644 index 00000000000..e839c2e017d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-incarnation-fallback.test.ts @@ -0,0 +1,534 @@ +import { afterEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { ORCHESTRATION_METHODS } from './orchestration' +import { eraseRpcMethods, type RpcContext } from '../core' +import { OrchestrationDb } from '../../orchestration/db' +import { OrcaRuntimeService } from '../../orca-runtime' +import { completeWorkerTerminalRelease } from './orchestration/worker/worker-release-completion' + +describe('orchestration worker release incarnation fallback', () => { + let db: OrchestrationDb + let dbOpen = false + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string + let inspectProcessLiveness: MockInstance< + OrcaRuntimeService['inspectTerminalProcessIncarnationLiveness'] + > + + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + + /** Fresh in-memory db and a fully-stubbed runtime for one worker-release scenario. */ + function setup(): void { + db = new OrchestrationDb(':memory:') + dbOpen = true + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + inspectProcessLiveness = vi + .spyOn(runtime, 'inspectTerminalProcessIncarnationLiveness') + .mockResolvedValue('live') + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' + ? coordinatorPaneKey + : handle === 'term_worker' || handle === 'term_reminted' + ? workerPaneKey + : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === 'term_worker' || handle === 'term_reminted' ? 'runtime_test:term_worker:1' : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === 'term_worker' || handle === 'term_reminted' + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. + ({ + terminalHandle: handle, + paneKey: workerPaneKey, + processIncarnation: 'runtime_test:term_worker:1', + hostScope: { kind: 'local', hostId: 'local' } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockImplementation( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: 'repo::worktree' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: 'worker' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['worker output line 1', 'worker output line 2'], + truncated: false, + nextCursor: '2' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + vi.spyOn(runtime, 'closeTerminal').mockResolvedValue({ + handle: 'term_worker', + tabId: 'tab-worker', + ptyKilled: true + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + } as never) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + activeRunId = db.createRun({ + objective: 'Release test Run', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }).id + ctx = { runtime } + } + + afterEach(() => { + if (dbOpen) { + dbOpen = false + db.close() + } + vi.restoreAllMocks() + }) + + /** Look up a registered orchestration RPC method by name. */ + function findMethod(name: string) { + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find((m) => m.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + return method + } + + /** Parse a method's params and invoke its handler against the shared ctx. */ + async function call(name: string, params: Record) { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + /** Start a ready worker on a fresh task off the coordinator terminal. */ + async function startWorker(options: { terminal?: string } = {}): Promise<{ + taskId: string + dispatchId: string + }> { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const task = db.createTask({ spec: 'release fixture task', runId: activeRunId }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + ...(options.terminal ? { terminal: options.terminal } : { agent: 'codex' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + })) as { dispatchId: string; state: string } + expect(result.state).toBe('ready') + return { taskId: task.id, dispatchId: result.dispatchId } + } + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + /** Record the worker's report as settled — the precondition for release. */ + function settle(taskId: string, dispatchId: string, outcome: 'succeeded' | 'failed'): void { + const settlement = db.settleWorkerReport({ + taskId, + dispatchId, + outcome, + result: `worker ${outcome}` + }) + expect(settlement.action).toBe('settled') + } + + /** Start a worker and settle its report, the state a release acts on. */ + async function startSettledWorker( + outcome: 'succeeded' | 'failed' = 'succeeded', + options: { terminal?: string } = {} + ): Promise<{ taskId: string; dispatchId: string }> { + const worker = await startWorker(options) + settle(worker.taskId, worker.dispatchId, outcome) + return worker + } + + it('closes a live worker terminal whose durable handle no longer resolves but whose process incarnation still matches', async () => { + setup() + const { dispatchId } = await startSettledWorker() + // The durable handle stops resolving (renderer graph epoch bump / handle invalidation)... + vi.mocked(runtime.showTerminal).mockImplementation(async (handle) => + handle === 'term_worker' + ? Promise.reject(new Error('terminal_handle_stale')) + : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + ({ handle, worktreeId: 'repo::worktree', status: 'running' } as never) + ) + // ...but the recorded process incarnation still names a live PTY, re-minted to a fresh handle. + const resolveByIncarnation = vi.fn().mockReturnValue('term_reminted') + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + expect(receipt).toMatchObject({ state: 'released', processAction: 'closed_agent_terminal' }) + expect(resolveByIncarnation).toHaveBeenCalledWith( + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + // The close targeted exactly the re-minted live handle for that PTY, never the stale one. + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + }) + + it('stays release_unknown and closes nothing when the recorded incarnation no longer matches a live pty', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + // A reused ptyId now belongs to a different process: the incarnation mismatch refuses a close. + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + + expect(receipt.state).toBe('release_unknown') + expect(resolveByIncarnation).toHaveBeenCalledWith( + 'runtime_test:term_worker:1', + JSON.stringify({ kind: 'local', hostId: 'local' }) + ) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('unknown') + }) + + it('does not plain-settle an exited missing worker when the archive was never committed', async () => { + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + inspectProcessLiveness.mockResolvedValue('exited') + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + expect(receipt).toMatchObject({ state: 'release_unknown', processAction: 'none' }) + expect(inspectProcessLiveness).toHaveBeenCalled() + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'unknown' + }) + }) + + it('settles released without a close when exited missing and an archive is already committed', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const requested = db.requestWorkerTerminalRelease(dispatchId) + if (requested.disposition !== 'requested') { + throw new Error(`expected requested, got ${requested.disposition}`) + } + db.commitWorkerTerminalArchiveForRelease({ + dispatchId, + resourceId: requested.resource.id, + kind: 'terminal_tail', + content: JSON.stringify({ lines: [] }), + archiveSource: 'terminal', + archiveStatus: 'empty' + }) + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + inspectProcessLiveness.mockResolvedValue('exited') + + const receipt = await completeWorkerTerminalRelease({ + runtime, + db, + dispatchId, + resource: db.getWorkerTerminalResource(requested.resource.id)!, + mode: 'interactive' + }) + + expect(receipt).toMatchObject({ state: 'released', processAction: 'none' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + }) + + it('reaches settleDead before the lease check when a gone worker is exited with no live authority', async () => { + // Without a committed archive settleDead retains; lease must not run first and force + // retained/identity_unproven. Disposition is release_unknown (interactive) after the retain. + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + vi.mocked(runtime.getOrchestrationDispatchAuthority).mockReturnValue(null) + inspectProcessLiveness.mockResolvedValue('exited') + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + expect(receipt).toMatchObject({ state: 'release_unknown', processAction: 'none' }) + expect(receipt.state).not.toBe('retained') + expect(inspectProcessLiveness).toHaveBeenCalled() + expect(runtime.closeTerminal).not.toHaveBeenCalled() + }) + + it('concedes release_unknown before the lease check when a gone worker has no live authority and liveness is unproven', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + vi.mocked(runtime.getOrchestrationDispatchAuthority).mockReturnValue(null) + // Liveness is unresolvable/not-exited: the process may have been re-homed, so concede rather + // than retain or guess at a live process. + inspectProcessLiveness.mockResolvedValue('live') + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + } + + expect(receipt.state).toBe('release_unknown') + expect(receipt.state).not.toBe('retained') + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('unknown') + }) + + it('workerStop closes a live worker via the reminted handle when the durable handle is stale', async () => { + setup() + const { dispatchId } = await startWorker() + // The durable handle stops resolving, but the recorded incarnation still names a live PTY. + vi.mocked(runtime.showTerminal).mockImplementation(async (handle) => + handle === 'term_worker' + ? Promise.reject(new Error('terminal_handle_stale')) + : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + ({ handle, worktreeId: 'repo::worktree', status: 'running' } as never) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const resolveByIncarnation = vi.fn().mockReturnValue('term_reminted') + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerStop', { dispatch: dispatchId })) as { + processAction: string + } + + expect(receipt.processAction).toBe('closed_agent_terminal') + // The kill targeted exactly the reminted live handle, never the stale durable one — closing + // the stale handle would throw terminal_handle_stale and leak the PTY. + expect(runtime.closeTerminal).toHaveBeenCalledTimes(1) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') + }) + + it('workerRead reads a live worker via the reminted handle when the durable handle is stale', async () => { + setup() + const { dispatchId } = await startWorker() + vi.mocked(runtime.showTerminal).mockImplementation( + async (handle) => + handle === 'term_worker' + ? Promise.reject(new Error('terminal_handle_stale')) + : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + ({ handle, worktreeId: 'repo::worktree', status: 'running' } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + ) + const resolveByIncarnation = vi.fn().mockReturnValue('term_reminted') + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const output = (await call('orchestration.workerRead', { dispatch: dispatchId })) as { + terminal?: { tail: string[] } + } + + // Both the exact-session probe and the terminal read addressed the reminted handle. + expect(runtime.getExactWorkerProviderSession).toHaveBeenCalledWith( + 'term_reminted', + expect.any(Number) + ) + expect(runtime.readTerminal).toHaveBeenCalledWith('term_reminted', expect.anything()) + expect(output.terminal?.tail).toEqual(['worker output line 1', 'worker output line 2']) + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + it('recovery-mode: defers when exited missing has no archive rather than plain-settling', async () => { + // Proof of death still runs settleDead first; when it retains (no archive), recovery must + // stay release_pending — not plain-settle, not unknown. + setup() + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + inspectProcessLiveness.mockResolvedValue('exited') + const requested = db.requestWorkerTerminalRelease(dispatchId) + if (requested.disposition !== 'requested') { + throw new Error(`expected a requested release, got ${requested.disposition}`) + } + + const receipt = await completeWorkerTerminalRelease({ + runtime, + db, + dispatchId, + resource: requested.resource, + mode: 'recovery' + }) + + expect(receipt).toMatchObject({ state: 'release_pending', processAction: 'none' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'requested' + }) + }) + + it('recovery-mode: settles released before the defer when exited missing already has an archive', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + setup() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const { dispatchId } = await startSettledWorker() + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + inspectProcessLiveness.mockResolvedValue('exited') + const requested = db.requestWorkerTerminalRelease(dispatchId) + if (requested.disposition !== 'requested') { + throw new Error(`expected a requested release, got ${requested.disposition}`) + } + db.commitWorkerTerminalArchiveForRelease({ + dispatchId, + resourceId: requested.resource.id, + kind: 'terminal_tail', + content: JSON.stringify({ lines: [] }), + archiveSource: 'terminal', + archiveStatus: 'empty' + }) + + const receipt = await completeWorkerTerminalRelease({ + runtime, + db, + dispatchId, + resource: db.getWorkerTerminalResource(requested.resource.id)!, + mode: 'recovery' + }) + + expect(receipt).toMatchObject({ state: 'released', processAction: 'none' }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + it('recovery-mode: defers release_pending when liveness is unverifiable rather than provably exited', async () => { + setup() + const { dispatchId } = await startSettledWorker() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + vi.mocked(runtime.showTerminal).mockRejectedValue(new Error('terminal_handle_stale')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const resolveByIncarnation = vi.fn().mockReturnValue(null) + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + resolveByIncarnation + ) + // Not a death certificate: inventory may still be incomplete, so recovery must defer. + inspectProcessLiveness.mockResolvedValue('unverifiable') + const requested = db.requestWorkerTerminalRelease(dispatchId) + if (requested.disposition !== 'requested') { + throw new Error(`expected a requested release, got ${requested.disposition}`) + } + + const receipt = await completeWorkerTerminalRelease({ + runtime, + db, + dispatchId, + resource: requested.resource, + mode: 'recovery' + }) + + expect(receipt.state).toBe('release_pending') + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).not.toBe('released') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-reap-fixed.func.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-reap-fixed.func.test.ts new file mode 100644 index 00000000000..579f37eaf9e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-reap-fixed.func.test.ts @@ -0,0 +1,358 @@ +// PRB-0219 / upstream #18737 — FUNCTIONAL (integration) reproduction of the steady-state +// worker-release reap LEAK. +// +// This is the "functional/integration" tier of the PRB-0219 test strategy. It wires the REAL +// orchestration RPC surface (orchestration.workerStart / workerRelease / workerList), the REAL +// OrchestrationDb, and the REAL release completion + observation modules against a fake runtime +// that faithfully models the two identity planes involved in the bug: +// +// * VOLATILE, epoch-fenced handle table — `handleTable` keyed by terminal handle, each entry +// stamped with the `rendererGraphEpoch` at which it was issued. `showTerminal` resolves a +// handle ONLY while its stamped epoch matches the current epoch (mirrors getLiveLeafForHandle +// throwing 'terminal_handle_stale' on a rendererGraphEpoch bump). A relay reconnect / renderer +// remount on headless serve bumps the epoch. +// * DURABLE, incarnation-addressed process table — `ptysById` keyed by the ptyId embedded in the +// worker's persisted process_incarnation (`${ptyId}:${incarnationId}`). The pty stays LIVE +// across an epoch bump; nothing about the graph epoch kills the process. +// +// The bug (mechanism): when the epoch bumps, the durable db handle stops resolving through +// showTerminal WHILE THE PTY IS STILL ALIVE. inspectWorkerTerminal swallows the throw and reports +// `missing`; completeWorkerTerminalRelease then commits `release_unknown` and returns WITHOUT ever +// calling runtime.closeTerminal — so the process/PTY leaks. On mtl-02 those orphans accumulate in +// the orca-serve@factory cgroup until TasksMax=4096 is hit and Bun/omp abort() on EAGAIN. +// +// The observable leak signal asserted here: state 'release_unknown' + processAction 'none' + +// closeTerminal NEVER called + the pty STILL alive in ptysById + worker-list terminalState stuck +// on 'release_unknown' (never 'released'). + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_METHODS } from './orchestration' +import { eraseRpcMethods, type RpcContext } from '../core' +import { OrchestrationDb } from '../../orchestration/db' +import { OrcaRuntimeService } from '../../orca-runtime' + +describe('PRB-0219 worker-release reap FIX (functional verification)', () => { + let db: OrchestrationDb + let dbOpen = false + let runtime: OrcaRuntimeService + let ctx: RpcContext + let activeRunId: string + + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + + // Dispatch/automation ptyId shape: `${repoId}::${worktreePath}@@${suffix}` (executionHostId:null). + const PTY_ID = 'repo-7f3a::/data/wt/factory-task-1@@a1b2c3d4' + const INCARNATION_ID = 1 + const PROCESS_INCARNATION = `${PTY_ID}:${INCARNATION_ID}` + + // ---- fake runtime process/handle planes (module-level so spies can mutate them) ---- + let ptysById: Map + let handleTable: Map + let rendererGraphEpoch: number + let closedPtyIds: string[] + + /** Resolve a handle to its live pty only while its stamped graph epoch is current. */ + function resolveHandleToLivePty( + handle: string + ): { ptyId: string; pty: { incarnationId: number; alive: boolean } } | null { + const entry = handleTable.get(handle) + if (!entry) { + return null + } + if (entry.epoch !== rendererGraphEpoch) { + // rendererGraphEpoch fence: the durable handle no longer resolves to a live leaf. + return null + } + const pty = ptysById.get(entry.ptyId) + if (!pty || !pty.alive) { + return null + } + return { ptyId: entry.ptyId, pty } + } + + /** Wire the real RPC surface and db against the two-plane fake runtime. */ + function setup(): void { + ptysById = new Map([ + ['coord-pty', { incarnationId: 1, alive: true }], + [PTY_ID, { incarnationId: INCARNATION_ID, alive: true }] + ]) + handleTable = new Map([ + ['term_coord', { ptyId: 'coord-pty', epoch: 0 }], + ['term_worker', { ptyId: PTY_ID, epoch: 0 }] + ]) + rendererGraphEpoch = 0 + closedPtyIds = [] + + db = new OrchestrationDb(':memory:') + dbOpen = true + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + + // Incarnation-addressed liveness probe: reads the DURABLE plane, so it stays 'live' across the + // epoch bump (the process really is still running). Matches the real asymmetry. + vi.spyOn(runtime, 'inspectTerminalProcessIncarnationLiveness').mockImplementation( + async (incarnation: string) => { + const idx = incarnation.lastIndexOf(':') + const ptyId = incarnation.slice(0, idx) + const pty = ptysById.get(ptyId) + return pty?.alive ? 'live' : 'exited' + } + ) + + // The incarnation-addressed re-resolution primitive the fix adds. Present here as a spy so the + // same harness proves BOTH tiers: pre-fix completion never calls it (leak); post-fix completion + // calls it to remint a live handle (reap). Fence: EXACT incarnationId match only. + vi.spyOn(runtime, 'resolveTerminalHandleByProcessIncarnation').mockImplementation( + (incarnation: string): string | null => { + const idx = incarnation.lastIndexOf(':') + const ptyId = incarnation.slice(0, idx) + const inc = Number(incarnation.slice(idx + 1)) + const pty = ptysById.get(ptyId) + if (!pty || !pty.alive) { + return null + } + if (pty.incarnationId !== inc) { + // Fence: a reused ptyId with a different incarnation must NOT resolve. + return null + } + // Remint a live handle at the current graph epoch. + handleTable.set('term_reminted', { ptyId, epoch: rendererGraphEpoch }) + return 'term_reminted' + } + ) + + // Identity plane (durable) — answers for the original AND any reminted handle. Independent of + // the graph epoch, exactly like the real getTerminal* accessors that read dispatch authority. + const knownWorkerHandle = (handle: string): boolean => + handle === 'term_worker' || handle === 'term_reminted' + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' + ? coordinatorPaneKey + : knownWorkerHandle(handle) + ? workerPaneKey + : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + knownWorkerHandle(handle) ? PROCESS_INCARNATION : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + knownWorkerHandle(handle) + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This test fixture is deliberately shaped to exercise the private/runtime boundary. + ({ + terminalHandle: handle, + paneKey: workerPaneKey, + processIncarnation: PROCESS_INCARNATION, + hostScope: { kind: 'local', hostId: 'local' } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + + // Volatile handle resolution — epoch-fenced. Throws 'terminal_handle_stale' once the epoch + // moves past the epoch at which the handle was issued. + vi.spyOn(runtime, 'showTerminal').mockImplementation(async (handle) => { + if (!resolveHandleToLivePty(handle)) { + throw new Error('terminal_handle_stale') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + return { handle, worktreeId: 'repo::worktree', status: 'running' } as never + }) + + // The reap: closing a handle kills exactly the pty it resolves to. + vi.spyOn(runtime, 'closeTerminal').mockImplementation(async (handle) => { + const live = resolveHandleToLivePty(handle) + if (!live) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + return { handle, tabId: null, ptyKilled: false } as never + } + live.pty.alive = false + ptysById.delete(live.ptyId) + closedPtyIds.push(live.ptyId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + return { handle, tabId: `tab:${live.ptyId}`, ptyKilled: true } as never + }) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // Remaining runtime surface required to start + settle a worker (mirrors the unit harness). + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: 'repo::worktree' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: 'worker' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['worker output line 1', 'worker output line 2'], + truncated: false, + nextCursor: '2' + }) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + activeRunId = db.createRun({ + objective: 'PRB-0219 reap leak fixture', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }).id + ctx = { runtime } + } + + afterEach(() => { + if (dbOpen) { + dbOpen = false + db.close() + } + vi.restoreAllMocks() + }) + + /** Look up a registered orchestration RPC method by name. */ + function findMethod(name: string) { + const method = eraseRpcMethods(ORCHESTRATION_METHODS).find((m) => m.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + return method + } + + /** Parse a method's params and invoke its handler against the shared ctx. */ + async function call(name: string, params: Record) { + const method = findMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + /** Start a worker and settle its report, the state a release acts on. */ + async function startSettledWorker(): Promise<{ taskId: string; dispatchId: string }> { + const task = db.createTask({ spec: 'reap-leak fixture task', runId: activeRunId }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + })) as { dispatchId: string; state: string } + expect(result.state).toBe('ready') + const settlement = db.settleWorkerReport({ + taskId: task.id, + dispatchId: result.dispatchId, + outcome: 'succeeded', + result: 'worker succeeded' + }) + expect(settlement.action).toBe('settled') + return { taskId: task.id, dispatchId: result.dispatchId } + } + + /** The terminalState workerList projects for a dispatch, or null. */ + async function workerTerminalState(dispatchId: string): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const listed = (await call('orchestration.workerList', { run: activeRunId })) as { + workers: { dispatchId: string; terminalState: string | null }[] + } + return listed.workers.find((w) => w.dispatchId === dispatchId)?.terminalState ?? null + } + + it('REAP (fixed): a rendererGraphEpoch bump strands the durable handle, but the incarnation fallback remints a live handle and reaps the process', async () => { + setup() + const { dispatchId } = await startSettledWorker() + const resource = db.getWorkerTerminalResourceByOwner(dispatchId) + expect(resource?.process_incarnation).toBe(PROCESS_INCARNATION) + + // Relay reconnect / renderer remount bumps the graph epoch: the durable handle goes stale + // while the PTY stays alive. + rendererGraphEpoch = 1 + await expect(runtime.showTerminal('term_worker')).rejects.toThrow('terminal_handle_stale') + expect(ptysById.get(PTY_ID)?.alive).toBe(true) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + // The fix: inspectWorkerTerminal re-resolved the live PTY by process incarnation, reminted a + // handle, and completion closed THAT handle — the process is actually reaped. + expect(receipt.state).toBe('released') + expect(receipt.processAction).toBe('closed_agent_terminal') + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_reminted') + expect(closedPtyIds).toEqual([PTY_ID]) + expect(ptysById.has(PTY_ID)).toBe(false) + expect(await workerTerminalState(dispatchId)).toBe('released') + }) + + it('CONTROL: with the graph epoch intact the same release reaps exactly that PTY', async () => { + setup() + const { dispatchId } = await startSettledWorker() + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + expect(receipt.state).toBe('released') + expect(receipt.processAction).toBe('closed_agent_terminal') + expect(closedPtyIds).toEqual([PTY_ID]) + expect(ptysById.has(PTY_ID)).toBe(false) + expect(await workerTerminalState(dispatchId)).toBe('released') + }) + + it('FENCE (fixed): a reused ptyId carrying a different incarnation must NOT remint or close — stays release_unknown', async () => { + setup() + const { dispatchId } = await startSettledWorker() + + // The graph epoch bumps AND the ptyId has been reused by a different process (incarnation 2), + // while the worker's recorded incarnation is still 1. The exact-incarnation fence must refuse. + rendererGraphEpoch = 1 + const reused = ptysById.get(PTY_ID) + if (reused) { + reused.incarnationId = 2 + } + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Test fixture crosses a private/runtime boundary with a verified shape. + const receipt = (await call('orchestration.workerRelease', { dispatch: dispatchId })) as { + state: string + processAction: string + } + + expect(receipt.state).toBe('release_unknown') + expect(receipt.processAction).toBe('none') + expect(runtime.closeTerminal).not.toHaveBeenCalled() + // The other lane's live process is left untouched (never reaped by an over-broad match). + expect(ptysById.get(PTY_ID)?.alive).toBe(true) + expect(await workerTerminalState(dispatchId)).toBe('release_unknown') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts index b076be06593..62d2b5da495 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.test.ts @@ -69,7 +69,7 @@ describe('orchestration.send group addresses', () => { return terminal ? `${terminal.tabId}:${terminal.leafId}` : null }) vi.spyOn(runtime, 'getAgentStatusForHandle').mockImplementation( - (handle: string) => agentStatuses?.[handle] ?? null + async (handle: string) => agentStatuses?.[handle] ?? null ) } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts index cb75bca2705..ebcff6c284e 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts @@ -148,7 +148,7 @@ export async function sendGroupMessage(args: { const { terminals } = await runtime.listTerminals(undefined, undefined, { includeVisualLayouts: false }) - agents = [...terminals, ...listAddressableStructuredWorkers()] + agents = [...terminals, ...listAddressableStructuredWorkers(db)] } // Revalidate after discovery before selecting recipients or writing mail. revalidateLegacyCoordinator?.() @@ -167,8 +167,23 @@ export async function sendGroupMessage(args: { agents, warnings: groupWarnings }) - const handles = resolveGroupAddress(groupAddress, from, candidates, (handle: string) => - runtime.getAgentStatusForHandle(handle) + // Read up front: a structured worker's status comes from its journal, which may need opening. + const statuses = + groupAddress.toLowerCase() === '@idle' + ? new Map( + await Promise.all( + candidates.map( + async (candidate) => + [candidate.handle, await runtime.getAgentStatusForHandle(candidate.handle)] as const + ) + ) + ) + : new Map() + const handles = resolveGroupAddress( + groupAddress, + from, + candidates, + (handle: string) => statuses.get(handle) ?? null ) if (handles.length === 0) { // Preserve the recovery addresses even when every worker was skipped. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts b/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts index 250b639fc60..758bc3397cb 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/failed-worker-start-teardown.ts @@ -8,10 +8,10 @@ import type { createStructuredWorkerSessionForWorktree } from './worker-topology /** * Undoes what a start created before it failed. * - * A start that never reached ready leaves no settlement to release the hold later, and its session - * was already published as a chat tab — without the discard, a failed start strands a dead chat tab - * that the durable restore index republishes on every app launch. Both halves are best-effort by - * construction, so neither can replace the real error. + * A start that never reached ready leaves no settlement to release its binding later, and its + * session was already published as a chat tab — without the discard, a failed start strands a dead + * chat tab that the durable restore index republishes on every app launch. Both halves are + * best-effort by construction, so neither can replace the real error. * * A created PTY terminal is deliberately NOT torn down: its custody row was written at creation, so * `worker-release` on the failed Dispatch owns that cleanup and the coordinator decides when. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-stop.ts b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-stop.ts index 4a32147d1a6..91e524e4bf1 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/structured-worker-release-stop.ts @@ -4,15 +4,15 @@ import type { WorkerTerminalResourceRow } from '../../../../orchestration/worker import { stopStructuredWorker } from '../../orchestration-structured-worker-lifecycle' import type { StructuredWorkerIdentity } from '../../../../structured-worker-identity' import { archiveSummary } from './worker-terminal-resource-presentation' -import type { WorkerReleaseReceipt } from './worker-release-completion' +import { releaseUnknownRecovery, type WorkerReleaseReceipt } from './worker-release-completion' /** * The close half of a release for a worker that IS a structured session. * * Separate from the PTY close for the same reason the delivery lane is: there is no terminal to * close and no exit to observe, so the host's own settlement is the only proof available. Only a - * proven close may settle; an unproven one reports `release_unknown` and stays retryable under the - * same request id. + * proven close may settle; an unproven one reports `release_unknown` and stays retryable, but only + * under a fresh request id — replaying the prior one just returns this same receipt. */ export async function stopStructuredWorkerForRelease(args: { structured: StructuredWorkerIdentity @@ -36,7 +36,7 @@ export async function stopStructuredWorkerForRelease(args: { processAction: stop.closeAttempted ? 'closed_agent_terminal' : 'none', archive: { source: args.archiveSource, status: args.archiveStatus }, lastError: unknown.release_error ?? stop.reason, - recovery: `Inspect with: orca orchestration worker-show --dispatch ${dispatchId} --json — then repeat worker-release with the same --retry-request.` + recovery: releaseUnknownRecovery(dispatchId) } } const settled = db.settleWorkerTerminalRelease(resource.id) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index 5e8babf3c5e..a9211532626 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -143,7 +143,10 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ `Worker Dispatch ${params.dispatch} no longer resolves to its exact process.` ) } - const structured = readStructuredWorkerOutput({ + // Read via the handle inspectWorkerTerminal proved live: the durable one, or a handle + // re-minted from the recorded incarnation after the durable handle went stale. + const liveHandle = observation.terminalHandle ?? terminalHandle + const structured = await readStructuredWorkerOutput({ db, dispatchId: params.dispatch, workerState: worker?.state ?? 'unsupervised', @@ -163,7 +166,7 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ const output = await readExactWorkerOutput({ runtime, dispatchId: params.dispatch, - terminalHandle, + terminalHandle: liveHandle, workerState: worker?.state ?? 'unsupervised', terminalStatus: observation.status === 'exited' @@ -200,9 +203,8 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ const abandoned = runtime.getOrchestrationDb().abandonWorkerDispatch(params.dispatch) if (abandoned.disposition === 'context_only') { if (!abandoned.alreadySettled) { - // Abandon settles the Dispatch, so it owes the same hold release stop and release do. - // A surviving hold pins the provider child for the life of the app and makes host crash - // recovery respawn a worker nobody is waiting on. + // Abandon settles the Dispatch, so it owes the same binding release stop and release do: + // a surviving redrive subscription keeps nudging a worker nobody is waiting on. releaseStructuredWorkerSession(params.dispatch, runtime) runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts index 83b3d020f4c..f1202c6e7cf 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-observation.ts @@ -9,12 +9,14 @@ import { observeStructuredWorker, resolveStructuredWorkerForDispatch } from '../../orchestration-structured-worker-lifecycle' +import { structuredWorkerAddressable } from '../../../../structured-worker-custody' import type { DispatchContextRow, FederatedDispatchRow, WorkerDispatchRow } from '../../../../orchestration/types' +/** Observe a worker terminal, re-minting a live handle from the recorded process incarnation when the durable handle went stale, so a still-running worker is never reported missing and leaked. */ export async function inspectWorkerTerminal( runtime: OrcaRuntimeService, db: OrchestrationDb, @@ -27,12 +29,18 @@ export async function inspectWorkerTerminal( reason?: string /** Set only on a proven-exact worker parked on a prompt that needs a human. */ agentWait?: RuntimeTerminalInteractiveWait | null + /** Structured workers only: whether mail still reaches it — at rest included, since the mail + * starts it. Absent when ownership cannot be read. `status` stays the process verdict. */ + addressable?: boolean + /** The handle that actually resolved: the durable one, or a live handle re-minted from the + * recorded process incarnation after the durable handle went stale. Null when none resolved. */ + terminalHandle: string | null }> { const worker = db.getWorkerDispatch(dispatchId) const terminalHandle = worker?.agent_terminal_handle ?? db.getDispatchContextById(dispatchId)?.assignee_handle if (!terminalHandle) { - return { terminal: null, exact: false, status: 'unattached' } + return { terminal: null, exact: false, status: 'unattached', terminalHandle: null } } const structured = resolveStructuredWorkerForDispatch(db, dispatchId) if (structured) { @@ -49,24 +57,54 @@ export async function inspectWorkerTerminal( processIncarnation: structured.processIncarnation }) const observation = observeStructuredWorker(structured) + const addressable = structuredWorkerAddressable( + db, + structured.sessionId, + db.getWorkerTerminalResourceByHandle?.(structured.handle) + ) return { terminal: null, exact, status: exact ? observation.status : 'identity_changed', - ...(exact && observation.reason ? { reason: observation.reason } : {}) + ...(exact && observation.reason ? { reason: observation.reason } : {}), + ...(exact && addressable !== null ? { addressable } : {}), + terminalHandle: null } } - const terminal = await runtime.showTerminal(terminalHandle).catch(() => null) + let effectiveHandle = terminalHandle + let terminal = await runtime.showTerminal(effectiveHandle).catch(() => null) if (!terminal) { - return { terminal: null, exact: false, status: 'missing' } + // Why: the durable handle resolves nowhere after a renderer graph epoch bump or handle + // invalidation, yet the recorded process incarnation may still name a live PTY. Re-mint a + // live handle (incarnation-fenced) so worker-show and release act on the still-running + // process instead of reporting it missing — which would leak the agent process tree. + // (workerList is a pure DB projection and never calls inspectWorkerTerminal.) + const resource = db.getWorkerTerminalResourceByOwner(dispatchId) + const reminted = resource?.process_incarnation + ? runtime.resolveTerminalHandleByProcessIncarnation( + resource.process_incarnation, + resource.host_scope + ) + : null + if (reminted) { + const remintedTerminal = await runtime.showTerminal(reminted).catch(() => null) + if (remintedTerminal) { + effectiveHandle = reminted + terminal = remintedTerminal + } + } + } + if (!terminal) { + // The re-mint above failed, so no live handle resolved; report the durable handle unresolved. + return { terminal: null, exact: false, status: 'missing', terminalHandle: null } } const exact = db.isDispatchProcessCurrent({ dispatchId, - paneKey: runtime.getTerminalPaneKey(terminalHandle), - processIncarnation: runtime.getTerminalProcessIncarnation(terminalHandle) + paneKey: runtime.getTerminalPaneKey(effectiveHandle), + processIncarnation: runtime.getTerminalProcessIncarnation(effectiveHandle) }) if (!exact) { - return { terminal, exact, status: 'identity_changed' } + return { terminal, exact, status: 'identity_changed', terminalHandle: effectiveHandle } } // Why: the aggregate inventory only iterates registered providers, so a dropped // relay clears `connected` for every remote PTY at once. Lost contact is not a @@ -76,38 +114,48 @@ export async function inspectWorkerTerminal( // Exact-gated by the early return above: a replaced process's prompt would attribute another // lane's blocker to this worker. const agentWait = terminal.agentWait - const verdict = runtime.getTerminalLivenessVerdict?.(terminalHandle) ?? null + const verdict = runtime.getTerminalLivenessVerdict?.(effectiveHandle) ?? null if (verdict?.status === 'unverifiable') { - return { terminal, exact, status: 'unverifiable', reason: verdict.reason, agentWait } + return { + terminal, + exact, + status: 'unverifiable', + reason: verdict.reason, + agentWait, + terminalHandle: effectiveHandle + } } if (verdict?.status === 'live') { - return { terminal, exact, status: 'live', agentWait } + return { terminal, exact, status: 'live', agentWait, terminalHandle: effectiveHandle } } if (!verdict) { const dispatch = db.getDispatchContextById?.(dispatchId) const persistedHostScope = parseWorkerTerminalHostScope(dispatch?.host_scope ?? null) - const currentHostScope = runtime.getOrchestrationDispatchAuthority?.(terminalHandle)?.hostScope + const currentHostScope = runtime.getOrchestrationDispatchAuthority?.(effectiveHandle)?.hostScope if (persistedHostScope?.kind === 'ssh' || currentHostScope?.kind === 'ssh') { return { terminal, exact, status: 'unverifiable', reason: 'missing_liveness_verdict', - agentWait + agentWait, + terminalHandle: effectiveHandle } } return { terminal, exact, status: terminal.connected === false ? 'exited' : 'live', - agentWait + agentWait, + terminalHandle: effectiveHandle } } return { terminal, exact, status: 'exited', - agentWait + agentWait, + terminalHandle: effectiveHandle } } @@ -119,7 +167,8 @@ export function exposeObservation(observation: Awaited { console.warn( '[orchestration] structured host install failed before release', @@ -121,6 +120,9 @@ async function completeWorkerTerminalReleaseOnce( } } const observation = await inspectWorkerTerminal(runtime, db, dispatchId) + // The live handle to act on: the durable one, or a handle re-minted from the recorded process + // incarnation when the durable handle went stale (inspectWorkerTerminal proved it live). + const terminalHandle = observation.terminalHandle ?? resource.terminal_handle if (observation.status === 'identity_changed') { const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') return { @@ -132,33 +134,39 @@ async function completeWorkerTerminalReleaseOnce( } } if (observation.status === 'missing' || observation.status === 'unattached') { - if (args.mode === 'recovery') { - // A close can succeed before the process crashes, leaving `releasing` durable state while - // terminal inventory no longer resolves the handle. Only a positive host liveness verdict - // may settle that exact incarnation; contact loss remains pending/unverifiable. - if (resource.process_incarnation) { - const processLiveness = await runtime.inspectTerminalProcessIncarnationLiveness( - resource.process_incarnation, - resource.host_scope - ) - if (processLiveness === 'exited') { - const reconciled = db.settleDeadWorkerTerminalRelease({ - requestingDispatchId: dispatchId, - resourceId: resource.id, - processIncarnation: resource.process_incarnation - }) - if (reconciled.disposition === 'released') { - runtime.notifyMessageArrived(`dispatch:${dispatchId}`, 'status') - return { - dispatchId, - state: 'released', - processAction: 'closed_exited_terminal', - archive: archiveSummary(reconciled.resource) - } + // Re-resolution by process incarnation (inspectWorkerTerminal) already failed, so no live PTY + // carries this worker's exact incarnation. If that incarnation is provably gone, settle + // released BEFORE the recovery defer: proof of death outranks deferral, so a provably-exited + // worker never languishes in release_pending across recovery passes. + if (resource.process_incarnation) { + const processLiveness = await runtime.inspectTerminalProcessIncarnationLiveness( + resource.process_incarnation, + resource.host_scope + ) + if (processLiveness === 'exited') { + // Prefer incarnation-fenced settle (dispatch relation + process_incarnation CAS). + const reconciled = db.settleDeadWorkerTerminalRelease({ + requestingDispatchId: dispatchId, + resourceId: resource.id, + processIncarnation: resource.process_incarnation + }) + if (reconciled.disposition === 'released') { + runtime.notifyMessageArrived(`dispatch:${dispatchId}`, 'status') + return { + dispatchId, + state: 'released', + processAction: 'none', + archive: archiveSummary(reconciled.resource) } } + // settleDead retains when the archive is still mandatory and missing (e.g. requested but + // never committed). Do NOT plain-settle: that would discard output and break recovery's + // "archive is mandatory" invariant. Fall through to recovery pending / unknown instead. } - // Inventory may still be incomplete during startup/reconnect discovery; defer. + } + if (args.mode === 'recovery') { + // No death certificate yet: inventory may still be incomplete during startup/reconnect + // discovery, so defer instead of guessing. return { dispatchId, state: 'release_pending', @@ -184,7 +192,7 @@ async function completeWorkerTerminalReleaseOnce( } } - if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, resource)) { + if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, resource, terminalHandle)) { const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') return { dispatchId, @@ -203,7 +211,7 @@ async function completeWorkerTerminalReleaseOnce( const captured = await captureWorkerOutputArchive({ runtime, dispatchId, - terminalHandle: resource.terminal_handle, + terminalHandle, attachedAtMs: orchestrationTimestampToMs(worker.created_at), structuredWorker: structured }) @@ -231,7 +239,7 @@ async function completeWorkerTerminalReleaseOnce( archive: archiveSummary(releasing) } } - if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, releasing)) { + if (!workerTerminalLeaseIsCurrent(runtime, db, dispatchId, releasing, terminalHandle)) { const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') return { dispatchId, @@ -254,7 +262,7 @@ async function completeWorkerTerminalReleaseOnce( archiveStatus }) } - const close = await runtime.closeTerminal(resource.terminal_handle) + const close = await runtime.closeTerminal(terminalHandle) if (!close.ptyKilled) { const reason = describeUnconfirmedAgentStop(close) const unknown = db.markWorkerTerminalReleaseUnknown(resource.id, reason) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts index dd4ce2522ea..d9798a47372 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-mobile-report.test.ts @@ -100,7 +100,7 @@ it.each(['unary', 'stream'])('mobile %s bytes do no orchestration database work' method.handler(method.params!.parse(params) as never, { runtime } as never) ).resolves.toMatchObject({ send: { accepted: true } }) } - expect(write).toHaveBeenCalledWith('pty-worker', 'x') + expect(write).toHaveBeenCalledWith('pty-worker', 'x', 'driving') expect(commit).toHaveBeenCalledTimes(1) expect(dbAccess).not.toHaveBeenCalled() expect(takeover).not.toHaveBeenCalled() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts new file mode 100644 index 00000000000..4caac62f5da --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-runtime-incarnation.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { inspectWorkerTerminal } from './worker-observation' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' +import { makePaneKey } from '../../../../../../shared/stable-pane-id' + +const PTY_ID = 'runtime_test:term_worker' +const LEAF_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + +describe('worker release through runtime incarnation recovery', () => { + const harness = createOrchestrationWorkerReleaseHarness() + afterEach(() => harness.cleanup()) + + it.each(['release', 'stop', 'read', 'replacement', 'disconnected'] as const)( + '%s uses runtime identity and close paths', + async (scenario) => { + harness.setup() + const { dispatchId } = + scenario === 'stop' ? await harness.startWorker() : await harness.startSettledWorker() + const runtime = harness.runtime + vi.mocked(runtime.showTerminal).mockRestore() + vi.mocked(runtime.getTerminalPaneKey).mockRestore() + vi.mocked(runtime.getTerminalProcessIncarnation).mockRestore() + vi.mocked(runtime.getOrchestrationDispatchAuthority).mockRestore() + vi.mocked(runtime.readTerminal).mockRestore() + vi.mocked(runtime.closeTerminal).mockRestore() + const kill = vi.fn(() => true) + runtime.setPtyController({ write: () => true, kill, getForegroundProcess: async () => null }) + runtime.registerPty(PTY_ID, 'repo::worktree', null, { + tabId: 'tab_worker', + leafId: LEAF_ID, + incarnationId: scenario === 'replacement' ? '2' : '1' + }) + await expect(runtime.showTerminal('term_worker')).rejects.toThrow() + if (scenario === 'disconnected') { + vi.spyOn(runtime, 'getTerminalLivenessVerdict').mockReturnValue({ + status: 'unverifiable', + reason: 'transport unavailable' + }) + const observed = await inspectWorkerTerminal(runtime, harness.db, dispatchId) + expect(observed).toMatchObject({ exact: true, status: 'unverifiable' }) + expect(kill).not.toHaveBeenCalled() + return + } + if (scenario === 'stop') { + const receipt = await harness.call('orchestration.workerStop', { dispatch: dispatchId }) + expect(receipt).toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) + expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID) + return + } + if (scenario === 'read') { + const readTerminal = vi.spyOn(runtime, 'readTerminal') + const output = await harness.call('orchestration.workerRead', { dispatch: dispatchId }) + // Pins the read to the reminted terminal: the handle it reached must resolve to the + // registered pane and incarnation, which the stale durable handle never does. + const [[readHandle]] = readTerminal.mock.calls + expect(runtime.getTerminalPaneKey(readHandle)).toBe(makePaneKey('tab_worker', LEAF_ID)) + expect(runtime.getTerminalProcessIncarnation(readHandle)).toBe(`${PTY_ID}:1`) + expect(output).toMatchObject({ + source: 'terminal', + fallbackReason: 'session_not_reported' + }) + expect(kill).not.toHaveBeenCalled() + return + } + const receipt = await harness.call('orchestration.workerRelease', { dispatch: dispatchId }) + if (scenario === 'replacement') { + expect(receipt).toMatchObject({ state: 'release_unknown', processAction: 'none' }) + expect(kill).not.toHaveBeenCalled() + return + } + expect(receipt).toMatchObject({ state: 'released', processAction: 'closed_agent_terminal' }) + expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID) + expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released' + }) + } + ) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts index 593d097580f..526b09a6320 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts @@ -381,6 +381,7 @@ describe('orchestration worker-start prompt contract', () => { const { runtime, handle } = await createAgentPromptSubmissionRuntime(() => undefined, 'codex') runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) const pending = runtime.sendTerminalAgentPrompt(handle, 'queued prompt', { + inputKind: 'driving', acceptQueued: true, requestId: 'busy-swallowed', observationTimeoutMs: 0 diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts index 98d3c376f61..ee0e73c904f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts @@ -141,6 +141,7 @@ export const ORCHESTRATION_WORKER_STOP_METHODS = [ }) } const observation = await inspectWorkerTerminal(runtime, db, params.dispatch) + const liveHandle = observation.terminalHandle ?? handle // The host exit can settle this stop while terminal inspection is awaiting inventory. if (db.getWorkerDispatch(params.dispatch)?.state === 'stopped') { runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') @@ -201,7 +202,7 @@ export const ORCHESTRATION_WORKER_STOP_METHODS = [ } } const closed = await runtime - .closeTerminal(handle) + .closeTerminal(liveHandle) .then((close) => ({ close }) as const) .catch( (error: unknown) => diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-release-lease.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-release-lease.ts index 50a97c9de4b..cad0ceb4128 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-release-lease.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-terminal-release-lease.ts @@ -9,23 +9,27 @@ export function workerTerminalLeaseIsCurrent( runtime: OrcaRuntimeService, db: OrchestrationDb, dispatchId: string, - resource: WorkerTerminalResourceRow + resource: WorkerTerminalResourceRow, + // Live (possibly reminted) handle for runtime probes. Durable identity still compares + // worker.agent_terminal_handle to resource.terminal_handle; after beginGraphReload the durable + // string is absent from the handle table, so authority/pane/incarnation must use the live one. + liveTerminalHandle: string ): boolean { const worker = db.getWorkerDispatch(dispatchId) if (isStructuredWorkerHandle(resource.terminal_handle)) { return structuredWorkerTerminalLeaseIsCurrent(db, dispatchId, worker, resource) } - const authority = runtime.getOrchestrationDispatchAuthority(resource.terminal_handle) + const authority = runtime.getOrchestrationDispatchAuthority(liveTerminalHandle) // Exited PTYs retain identity and host evidence but no longer mint launch authority. return Boolean( worker?.agent_terminal_handle === resource.terminal_handle && (authority ? resource.host_scope === JSON.stringify(authority.hostScope) - : runtime.getTerminalLivenessVerdict(resource.terminal_handle)?.status === 'exited') && + : runtime.getTerminalLivenessVerdict(liveTerminalHandle)?.status === 'exited') && db.isDispatchProcessCurrent({ dispatchId, - paneKey: runtime.getTerminalPaneKey(resource.terminal_handle), - processIncarnation: runtime.getTerminalProcessIncarnation(resource.terminal_handle) + paneKey: runtime.getTerminalPaneKey(liveTerminalHandle), + processIncarnation: runtime.getTerminalProcessIncarnation(liveTerminalHandle) }) && !db.workerTerminalResourceHasIdentityConflict(resource.id) ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts index de62b6b5b52..554c0912ff0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts @@ -32,16 +32,16 @@ describe('admission revoked while a session is still open', () => { it.each(CLEANUP_METHODS)( 'still serves $method after the host setting is turned off', - async ({ method, params, hostCall }) => { - const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF) + async (entry) => { + const response = await call(entry.method, entry.params, STRUCTURED_CLIENT, SETTING_OFF) expect(response).toMatchObject({ ok: true }) - // `unsubscribe` retires runtime-owned subscriptions rather than calling the host, so its - // result payload is the observable effect. - if (hostCall === 'unsubscribe') { - expect(response).toMatchObject({ result: { unsubscribed: true } }) + // `unsubscribe` retires runtime-owned subscriptions and `release` is a no-op, so neither + // calls the host: the result payload is the observable effect. + if (entry.hostCall === null) { + expect(response).toMatchObject({ result: entry.result }) } else { - expect(hostCalls[hostCall]).toHaveBeenCalled() + expect(hostCalls[entry.hostCall]).toHaveBeenCalled() } } ) 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 new file mode 100644 index 00000000000..df00957215f --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -0,0 +1,499 @@ +// 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. + +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 +} 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 { + createRestTestRig, + foundRestTestChat, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + REST_TEST_THREAD, + restTestSend, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import * as providerSupport from '../../../native-chat/agent-session-wire/structured-agent-session-provider-support' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +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' + +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + connectionId: 'connection-1' +} + +let rig: RestTestRig +let dispatcher: RpcDispatcher +let requests = 0 + +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + requests += 1 + await dispatcher.dispatchStreaming( + { id: `request-${requests}`, authToken: 'token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) + return replies +} + +/** A chat that once ran, reopened by a fresh host: nothing of it is in memory. */ +async function restingChat(): Promise { + await foundRestTestChat(rig) + await rig.store.replaceSessionOptions({ + sessionId: SESSION, + fence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'gpt-saved', effort: 'high' }, + now: rig.clock.now + }) + await rig.restart() + setStructuredAgentSessionHost(rig.host) + rig.adapter.acquire.mockClear() + rig.adapter.dispatch.mockClear() + rig.adapter.readOptions.mockClear() +} + +beforeEach(async () => { + requests = 0 + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockImplementation( + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC gate reads only this one setting. + ({ experimentalStructuredNativeChat: true }) as ReturnType< + OrcaRuntimeService['getClientSettings'] + > + ) + dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +describe('opening a chat at rest (P2-01)', () => { + it('reads, subscribes and answers everything without starting an agent', async () => { + await restingChat() + expect(rig.host.hasSession(SESSION)).toBe(false) + + const [history] = await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }) + const frames = await call('agentSession.subscribe', { sessionId: SESSION }) + const [options] = await call('agentSession.options', { sessionId: SESSION }) + const [commands] = await call('agentSession.commands', { sessionId: SESSION }) + const [outline] = await call('agentSession.conversationOutline', { sessionId: SESSION }) + const [status] = await call('agentSession.handoffStatus', { sessionId: SESSION }) + const [held] = await call('agentSession.hold', { sessionId: SESSION, holderId: 'pane' }) + + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(history).toMatchObject({ ok: true, result: { ok: true } }) + const snapshot = frames.find((frame) => frame.ok && 'result' in frame) + expect(snapshot).toMatchObject({ result: { type: 'snapshot', commands: null } }) + expect(options).toMatchObject({ + ok: true, + result: { current: { model: 'gpt-saved', effort: 'high' } } + }) + // Absent, never an empty list: the composer keeps its own menu. + expect(commands).toMatchObject({ ok: true, result: {} }) + expect(commands?.ok && 'result' in commands && commands.result).not.toHaveProperty( + 'commands', + [] + ) + expect(outline).toMatchObject({ ok: true }) + expect(status).toMatchObject({ ok: true }) + expect(held).toMatchObject({ ok: true, result: { held: true } }) + }) + + // Worktree activation asks this for every chat tab in the worktree. + it('answers the owner check from the record without opening the chat', async () => { + await restingChat() + + const [status] = await call('agentSession.handoffStatus', { sessionId: SESSION }) + + expect(status).toMatchObject({ ok: true, result: { owner: expect.any(String) } }) + expect(rig.host.hasSession(SESSION)).toBe(false) + }) + + it('starts the agent on the first send (P2-01)', async () => { + await restingChat() + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + const sent = await rig.host.send(CALLER, restTestSend('wake up', fence)) + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + // Awaits the provider's answer itself rather than polling for it, however slow the start. + await rig.host.waitForSendSettlement(SESSION, sent.value.clientMessageId) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.dispatch).toHaveBeenCalledOnce() + }) +}) + +describe('the accessor', () => { + it('opens a closed conversation once, however many readers arrive together (P2-02)', async () => { + // Written by the provider alone, so nothing but the readers below ever opens it here. + const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + expect(attached.ok).toBe(true) + rig.adapter.acquire.mock.calls + .at(-1)?.[0] + .events?.appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, + hostTestMessage('from the provider') + ) + await rig.host.flushStreamedEvents(SESSION) + await rig.restart() + setStructuredAgentSessionHost(rig.host) + rig.adapter.acquire.mockClear() + const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open') + await Promise.all([ + ...Array.from({ length: 5 }, () => + call('agentSession.history', { sessionId: SESSION, direction: 'tail' }) + ), + call('agentSession.subscribe', { sessionId: SESSION }) + ]) + const openedJournals = new Set( + await Promise.all(open.mock.results.map((result) => result.value)) + ) + expect(openedJournals.size).toBe(1) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + }) + + it('opens nothing once quit began, for a read that was already waiting on the lock', async () => { + await restingChat() + // The host's per-session queue, held so the read waits behind it. + const { serialize, lifetime } = rig.host.collaboratorsForTests() + let release = (): void => undefined + const held = new Promise((started) => { + void serialize(SESSION, () => { + started() + return new Promise((resolve) => (release = resolve)) + }) + }) + await held + const read = rig.host.history({ sessionId: SESSION, direction: 'tail' }) + + // Disposed as quit's first teardown step does. + lifetime.dispose() + release() + + await expect(read).rejects.toThrow() + expect(rig.host.hasSession(SESSION)).toBe(false) + }) + + it('refuses a read it cannot open with the reason, under the same code and message', async () => { + const [missing] = await call('agentSession.history', { + sessionId: 'session-never-created', + direction: 'tail' + }) + expect(missing).toMatchObject({ + ok: false, + error: { + code: 'agent_session_identity_required', + message: 'agent_session_identity_required', + data: { + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'recordMissing' } + } + } + } + }) + + await restingChat() + vi.spyOn(providerSupport, 'adapterSupportsRecord').mockReturnValue(false) + const [unsupported] = await call('agentSession.history', { + sessionId: SESSION, + direction: 'tail' + }) + expect(unsupported).toMatchObject({ + ok: false, + error: { + // Not a passthrough code: released clients match the message, as before. + code: 'runtime_error', + message: 'structured_agent_session_unsupported', + data: { refusal: { details: { reason: 'hostUnsupported' } } } + } + }) + }) + + it('refuses a read whose journal will not open with the classified reason, never the storage text', async () => { + await restingChat() + const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open') + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const storagePath = '/Users/someone/.orca/journals/session-1/journal.sqlite' + const failWith = (error: Error): void => { + open.mockRejectedValue(error) + } + const failures = async (): Promise => + [ + ...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' })), + ...(await call('agentSession.subscribe', { sessionId: SESSION })), + ...(await call('agentSession.options', { sessionId: SESSION })) + ].filter((reply) => !reply.ok) + + failWith( + Object.assign(new Error(`file is not a database: ${storagePath}`), { + code: 'ERR_SQLITE_ERROR', + errcode: 26 + }) + ) + const corrupt = await failures() + failWith(Object.assign(new Error(`EACCES: permission denied, open '${storagePath}'`), {})) + const unavailable = await failures() + + for (const [replies, reason] of [ + [corrupt, 'journalCorrupt'], + [unavailable, 'journalUnavailable'] + ] as const) { + expect(replies).toHaveLength(3) + for (const reply of replies) { + expect(reply).toMatchObject({ + ok: false, + error: { + // Not a passthrough code: released clients read the message, which stays the code. + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } + } + }) + expect(JSON.stringify(reply)).not.toContain(storagePath) + } + } + }) + + it('logs a reader reconnecting to a journal that will not open once per failure', async () => { + await restingChat() + const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const logged = (): unknown[] => + warn.mock.calls + .filter(([line]) => line === '[agent-session] opening the conversation for a read failed:') + .map(([, error]) => error) + const reconnect = async (): Promise => [ + ...(await call('agentSession.subscribe', { sessionId: SESSION })), + ...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' })) + ] + const denied = new Error('EACCES: permission denied') + const exhausted = new Error('EMFILE: too many open files') + + open.mockRejectedValue(denied) + for (let attempt = 0; attempt < 4; attempt += 1) { + // Every attempt is still refused with its reason; only the log is quiet. + expect((await reconnect()).filter((reply) => !reply.ok)).toHaveLength(2) + } + expect(logged()).toEqual([denied]) + open.mockRejectedValue(exhausted) + await reconnect() + await reconnect() + expect(logged()).toEqual([denied, exhausted]) + }) + + 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() + } + await rig.restart() + setStructuredAgentSessionHost(rig.host) + + const frames = await call('agentSession.subscribe', { sessionId: SESSION }) + expect(frames.some((frame) => !frame.ok)).toBe(false) + expect(frames.find((frame) => frame.ok)).toMatchObject({ result: { type: 'snapshot' } }) + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + expect((await rig.host.send(CALLER, restTestSend('after the repair', fence))).ok).toBe(true) + }) + + it('subscribes an old mobile client that holds first even when no agent can start (P2-06)', async () => { + await restingChat() + rig.adapter.acquire.mockRejectedValue(new Error('auth expired')) + + const [held] = await call('agentSession.hold', { sessionId: SESSION, holderId: 'mobile' }) + const frames = await call('agentSession.subscribe', { sessionId: SESSION }) + expect(held).toMatchObject({ ok: true }) + expect(frames.find((frame) => frame.ok)).toMatchObject({ result: { type: 'snapshot' } }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + }) +}) + +describe('options at rest', () => { + it('records a pick as intent and replays it at the next start (P2-16)', async () => { + await restingChat() + const fields = { key: 'model', value: 'gpt-picked' } + const [picked] = await call('agentSession.setOption', { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields + }) + }, + ...fields + }) + expect(picked).toMatchObject({ ok: true, result: { ok: true } }) + expect(rig.store.getRecord(SESSION)?.options).toMatchObject({ model: 'gpt-picked' }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + await rig.host.send(CALLER, restTestSend('use the new model', fence)) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + expect(rig.adapter.acquire.mock.calls[0]?.[0]).toMatchObject({ + options: expect.objectContaining({ model: 'gpt-picked' }) + }) + }) + + it('answers the provider-level features of a chat at rest (P2-17)', async () => { + await restingChat() + Object.assign(rig.host.deps.adapter, { + supportsThreadGoal: (_id: string, agent?: string) => agent === 'codex', + recordsContextUsage: (_id: string, agent?: string) => agent === 'claude', + rewindSupport: (_id: string, agent?: string) => + agent === 'codex' ? { supported: true } : { supported: false, reason: 'unsupported' } + }) + const [options] = await call('agentSession.options', { sessionId: SESSION }) + expect(options).toMatchObject({ + ok: true, + result: { threadGoal: { current: null }, rewind: { supported: true } } + }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.readOptions).not.toHaveBeenCalled() + }) +}) + +describe('an agent exit', () => { + it('is shown, not respawned; the next send starts the agent (P2-18)', async () => { + await foundRestTestChat(rig) + const running = rig.host.collaboratorsForTests().sessions.get(SESSION)!.child! + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'killed', + cause: 'unexpected-exit', + fence: running.fence, + acquisitionGeneration: running.generation! + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(rig.adapter.acquire).toHaveBeenCalledOnce() + + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + await rig.host.send(CALLER, restTestSend('again', fence)) + await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) + }) + + it('whose settlement write failed is settled by the next send, which is delivered', async () => { + await foundRestTestChat(rig) + const open = rig.host.collaboratorsForTests().sessions.get(SESSION)! + const running = open.child! + // A turn in flight, so the exit has something to settle. + rig.adapter.acquire.mock.calls + .at(-1)?.[0] + .events?.appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, + { kind: 'turn', turnId: 'working', state: 'running' } + ) + await rig.host.flushStreamedEvents(SESSION) + vi.spyOn(open.journal, 'appendLifecycleBatch').mockRejectedValueOnce(new Error('disk full')) + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'killed', + cause: 'unexpected-exit', + fence: running.fence, + acquisitionGeneration: running.generation! + }) + // Released with its death evidence, not latched: the next acquire settles from that evidence. + await vi.waitFor(() => + expect(rig.store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed' } + }) + ) + + const sent = await rig.host.send( + CALLER, + restTestSend('again', rig.store.getRecord(SESSION)!.lease.runtimeFence) + ) + + if (!sent.ok) { + throw new Error(`send refused: ${sent.refusal.code}`) + } + await rig.host.waitForSendSettlement(SESSION, sent.value.clientMessageId) + expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2) + expect( + activeStructuredAgentSessionTurnId((await rig.host.journalSnapshot(SESSION)).items) + ).not.toBe('working') + }) +}) + +describe('every close withdraws what is queued (P2-29)', () => { + it.each([ + ['the close RPC', async () => void (await call('agentSession.close', { sessionId: SESSION }))], + ['the runtime chat close', async () => void (await closeStructuredAgentSessionChild(SESSION))], + [ + 'a discarded worker', + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the discard reads only this runtime member. + discardStructuredWorkerSession(SESSION, { + retireStructuredAgentSessionTabFromSnapshot: () => undefined + } as never) + ] + ])('%s rejects a message accepted before any start, and starts nothing', async (_, close) => { + await restingChat() + // The delivery loop has not reached its first start yet. + const { loop } = rig.host.collaboratorsForTests().conversationDelivery + vi.spyOn(loop, 'wake').mockImplementation(() => undefined) + const reader: unknown[] = [] + await rig.host.subscribe({ + id: 'reader', + sessionId: SESSION, + emit: (event) => reader.push(event) + }) + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + expect((await rig.host.send(CALLER, restTestSend('closed before it went', fence))).ok).toBe( + true + ) + + await close() + // A close's rejection is a sentence with its fact beside it, never a marker. + await vi.waitFor(() => + expect(JSON.stringify(reader)).toContain( + '"reason":"The chat closed before this message was sent.","submittedAt"' + ) + ) + expect(JSON.stringify(reader)).toContain('"rejection":{"kind":"chatClosed"}') + expect(rig.host.hasSession(SESSION)).toBe(false) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts index 2b62b00525e..8a9e2267a5a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, @@ -63,7 +63,6 @@ describe('background-task stop capability at the RPC boundary', () => { it.each(['snapshot', 'batch', 'reset'] as const)( 'gates the %s stream without changing the provider state', async (type) => { - hostCalls.hold = vi.fn(async () => undefined) hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => { const base = { sessionId: SESSION, fence: 1, backgroundTasks: TASKS } if (type === 'batch') { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts index 99bf60da727..08ecc212ec1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts @@ -7,7 +7,7 @@ import { readAgentSessionConversationOutline } from '../../../native-chat/agent-session-wire/agent-session-conversation-outline' import { defineMethod } from '../core' -import { requireStructuredHost as requireHost } from './structured-agent-session-gate' +import { requireInstalledStructuredHost } from './structured-agent-session-gate' import { OptionsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [ @@ -15,6 +15,8 @@ export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [ name: 'agentSession.conversationOutline', params: OptionsParams, handler: async (params, ctx) => - readAgentSessionConversationOutline(requireHost(ctx).journalSnapshot(params.sessionId)) + readAgentSessionConversationOutline( + await (await requireInstalledStructuredHost(ctx)).journalSnapshot(params.sessionId) + ) }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 1a385922d2a..0c3b7b08713 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -12,6 +12,7 @@ * in. Both callers run the same two halves, so orchestration gets that guarantee too. */ +import { refuse } from '../../../../shared/agent-session-wire-refusals' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import type { AgentSessionAttachResult, @@ -144,10 +145,11 @@ export async function commitStructuredAgentSessionCreate(args: { console.warn('[agent-session] create committed before tab publication failed', error) return { ok: false, - refusal: { - code: 'agent_session_operation_unknown', - message: 'The chat may have been created, but its tab could not be confirmed.' - } + refusal: refuse( + 'agent_session_operation_unknown', + { reason: 'tabUnconfirmed' }, + 'The chat may have been created, but its tab could not be confirmed.' + ) } } // Read after publishing, which is what gives the chat its tab. 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 72c52c888fd..7b388f5eca2 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 @@ -21,15 +21,18 @@ export const CLEANUP_METHODS = [ params: { envelope: envelope(), turnId: 'turn-1' }, hostCall: 'cancel' }, + // A no-op kept for older clients: it answers without reaching the host. { method: 'agentSession.release', params: { sessionId: SESSION, holderId: 'surface-1' }, - hostCall: 'release' + hostCall: null, + result: { released: true } }, { method: 'agentSession.unsubscribe', params: { sessionId: SESSION }, - hostCall: 'unsubscribe' + hostCall: null, + result: { unsubscribed: true } } ] as const 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 95e3e3d6eee..e4ff861d1ac 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -8,6 +8,7 @@ // for old mobile clients while structured chat is enabled so they receive a fallback row, and that // path constructs the host. `agentSession.*` stays refused either way, which is what this gate is for. +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { getStructuredAgentSessionHost } 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 { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' @@ -27,7 +28,9 @@ export function supportsStructuredSessions(ctx: RpcContext): boolean { export function requireStructuredCapability(ctx: RpcContext): void { if (!supportsStructuredSessions(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } } @@ -35,7 +38,9 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo requireStructuredCapability(ctx) const host = getStructuredAgentSessionHost() if (!host) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostDisabled' + }) } return host } @@ -64,19 +69,22 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo */ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSessionHost { if (!supportsStructuredAgentSessionCapability(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } const host = getStructuredAgentSessionHost() if (!host) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostDisabled' + }) } return host } -/** Builds the host for the calls that address a session by durable record rather than by live - * state: attach, which is the only way a session comes into being, plus hold and reveal, which - * each reach for a record on disk this process may not have opened yet. Every other method - * addresses a session that must already be attached, and correctly reports absent when none is. */ +/** Builds the host for a call that may be the first this process sees. Every session is addressed + * by its durable record — a read opens a conversation at rest — so each call that reaches for one + * may meet a host nothing has built yet. */ export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { // Gated first: a client that cannot read structured sessions must not be able // to make the host exist, which is an observable side effect of the surface. @@ -89,6 +97,14 @@ export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { + await ensureStructuredHostInstalled(ctx) + return requireStructuredHost(ctx) +} + /** Mirrors the existing agent-session host-authority derivation so one client * gets one operation namespace across both surfaces. */ export function structuredCallerFor(ctx: RpcContext): StructuredAgentSessionCaller { 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 c3099d843ce..140be5e1c39 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 @@ -1,8 +1,8 @@ -// The wire half of a session's lifetime: who takes a hold, and what happens when they vanish. +// `agentSession.hold` / `release` are kept answering for clients that still send them, and do +// nothing else: a view never starts or keeps an agent. // -// Run against the REAL subscription registry rather than a stub, because the backstop being tested -// IS that registry's connection sweep — a stubbed `registerSubscriptionCleanup` would prove that -// the handler called a function, which is not the claim. +// Run against the REAL subscription registry rather than a stub, because the claim includes that a +// connection closing runs nothing hold-related — a stubbed registry could not show that. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -24,9 +24,10 @@ 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' const CONNECTION = 'connection-1' -const GRACE_MS = 5 const CLIENT = { clientId: 'device-1', clientKind: 'runtime' as const, @@ -82,7 +83,12 @@ beforeEach(async () => { // acquire that throws leaves an unverifiable owner nothing may replace. releaseAcquisition: vi.fn(async () => true), closeSession, - dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + dispatch: async () => ({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + }), cancelTurn: async () => ({ cancelled: false }), answerPrompt: async () => undefined, setOption: async () => undefined @@ -90,7 +96,6 @@ beforeEach(async () => { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - releaseGraceMs: GRACE_MS, now: () => NOW }) setStructuredAgentSessionHost(host) @@ -114,186 +119,85 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('a client that holds a session', () => { - it('keeps the provider child while the hold stands', async () => { +describe('the hold surface, for clients that still call it', () => { + it('answers a hold without starting an agent or registering a cleanup', async () => { + await host.close(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') + const acquiresBefore = acquire.mock.calls.length + expect( await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true }) + ).toMatchObject({ ok: true, result: { held: true } }) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) + expect(acquire.mock.calls.length).toBe(acquiresBefore) + expect(registered).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + }) + + it('answers a release without stopping anything, and a connection close runs nothing', async () => { + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + + expect( + await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: true, result: { released: true } }) + runtime.cleanupSubscriptionsForConnection(CONNECTION) + await new Promise((resolve) => setTimeout(resolve, 20)) expect(closeSession).not.toHaveBeenCalled() expect(host.hasSession(SESSION)).toBe(true) }) - it('releases it when the client says so', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - - expect( - await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true }) - - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('releases its hold and cleanup after the setting is disabled', async () => { - const release = vi.spyOn(host, 'release') - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + it('refuses a hold once the setting is off, and still answers a release', async () => { structuredNativeChatEnabled = false + expect( + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: false }) expect( await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true }) - const releaseCallsAfterRpc = release.mock.calls.length - runtime.cleanupSubscriptionsForConnection(CONNECTION) - - expect(releaseCallsAfterRpc).toBe(2) - expect(release).toHaveBeenCalledTimes(releaseCallsAfterRpc) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) + ).toMatchObject({ ok: true, result: { released: true } }) + expect(closeSession).not.toHaveBeenCalled() }) - it('does not report success when no provider child can be acquired', async () => { - const response = await call('agentSession.hold', { - sessionId: 'session-missing', - holderId: 'chat-missing' - }) + it('answers a hold even when no agent could be started', async () => { + await host.close(SESSION) + acquire.mockRejectedValue(new Error('provider unavailable')) + const acquiresBefore = acquire.mock.calls.length - expect(response).toMatchObject({ - ok: false, - error: { code: 'agent_session_identity_required' } - }) - expect(host.isHeld('session-missing')).toBe(false) + expect( + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: true, result: { held: true } }) + expect(acquire.mock.calls.length).toBe(acquiresBefore) }) }) -describe('a client that disappears without cleanup', () => { - it('shares one child with a same-ID replacement that arrives while the first hold resumes', async () => { +describe('a stream', () => { + it('reads a closed conversation without starting its agent', async () => { await host.close(SESSION) - await host.restoreReadableSessions() - closeSession.mockClear() - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - entered.resolve() - await gate.promise - return spawnChild(input) - }) - try { - const params = { sessionId: SESSION, holderId: 'same-chat' } - const first = call('agentSession.hold', params) - await entered.promise - // Re-registering the cleanup id released the first hold; the replacement waits its turn - // behind the first hold's attach and finds the child it made. - const replacement = call('agentSession.hold', params) - gate.resolve() + expect(host.hasSession(SESSION)).toBe(false) + const acquiresBefore = acquire.mock.calls.length + const frames: unknown[] = [] - expect(await first).toMatchObject({ ok: true }) - expect(await replacement).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledOnce() - expect(host.isHeld(SESSION)).toBe(true) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 4)) - expect(closeSession).not.toHaveBeenCalled() - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - } finally { - gate.resolve() - } - }) - - it('lets a same-ID replacement make its own attempt when the first hold fails to acquire', async () => { - await host.close(SESSION) - await host.restoreReadableSessions() - closeSession.mockClear() - acquire.mockClear() - const entered = Promise.withResolvers() - const gate = Promise.withResolvers() - acquire.mockImplementationOnce(async () => { - entered.resolve() - await gate.promise - throw new Error('acquisition failed') - }) - try { - const params = { sessionId: SESSION, holderId: 'same-chat' } - const first = call('agentSession.hold', params) - await entered.promise - const replacement = call('agentSession.hold', params) - gate.resolve() - - expect(await first).toMatchObject({ ok: false }) - // One attempt per hold: the replacement's own succeeds, and the holder it re-took stands. - const replaced = await replacement - expect(replaced, JSON.stringify(replaced)).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(host.isHeld(SESSION)).toBe(true) - expect(closeSession).not.toHaveBeenCalled() - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - } finally { - gate.resolve() - } - }) - - it('still releases the session when its transport closes', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('does not release a hold another connection is still holding', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) await dispatcher.dispatchStreaming( { - id: 'request-other', + id: 'request-subscribe', authToken: 'token', - method: 'agentSession.hold', - params: { sessionId: SESSION, holderId: 'chat-1' } + method: 'agentSession.subscribe', + params: { sessionId: SESSION } }, - () => {}, - { ...CLIENT, clientId: 'device-2', connectionId: 'connection-2' } + (raw) => frames.push(JSON.parse(raw)), + CLIENT ) - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - - it('does not let an old connection sweep release its same-document replacement', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - await dispatcher.dispatchStreaming( - { - id: 'request-replacement', - authToken: 'token', - method: 'agentSession.hold', - params: { sessionId: SESSION, holderId: 'chat-1' } - }, - () => {}, - { ...CLIENT, connectionId: 'connection-2' } + expect(frames).toContainEqual( + expect.objectContaining({ result: expect.objectContaining({ type: 'snapshot' }) }) ) - - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - - runtime.cleanupSubscriptionsForConnection('connection-2') - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(acquire.mock.calls.length).toBe(acquiresBefore) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) - it('releases a desktop subscription when its renderer transport dies', async () => { + it('keeps nothing alive when its transport dies', async () => { const transport = new AbortController() await dispatcher.dispatchStreaming( { @@ -310,56 +214,12 @@ describe('a client that disappears without cleanup', () => { clientCapabilities: CLIENT.clientCapabilities } ) - expect(host.isHeld(SESSION)).toBe(true) transport.abort() + await new Promise((resolve) => setTimeout(resolve, 20)) - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('unsubscribes and releases stream retention after the setting is disabled', async () => { - await dispatcher.dispatchStreaming( - { - id: 'stream-disabled-cleanup', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - () => {}, - CLIENT - ) - expect(host.isHeld(SESSION)).toBe(true) - structuredNativeChatEnabled = false - - expect( - await call('agentSession.unsubscribe', { - sessionId: SESSION, - subscriptionId: 'stream-disabled-cleanup' - }) - ).toMatchObject({ ok: true }) - - await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) - expect(closeSession).toHaveBeenCalledWith(SESSION) - }) - - it('does not let a stream alone resume a released session', async () => { - await host.close(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - await host.restoreReadableSessions() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - - await dispatcher.dispatchStreaming( - { - id: 'request-subscribe', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - () => {}, - CLIENT - ) - - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + // The idle sweep, not a departing reader, is what stops an agent. + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index 54753fcb889..e1975f1c725 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -1,64 +1,31 @@ -// `agentSession.hold` / `agentSession.release` — a surface saying it is bound to a session. +// `agentSession.hold` / `agentSession.release` — kept answering for clients that still send them. // -// The holder identity is scoped to the CONNECTION, not taken from the client verbatim: two clients -// are free to name their surfaces the same thing, and a hold that collides is one that a stranger -// can release. -// -// The registered cleanup is the backstop, and the ONLY thing that covers a client which vanishes — -// a paired client that disconnects mid-turn never gets to send its release. Registering it before taking -// the hold is deliberate: re-registering an id runs the previous cleanup synchronously, so the -// stale release lands before this hold rather than after it. +// A view no longer decides whether an agent runs: a send starts one, and the idle sweep stops it. +// So both are no-ops. `hold` still builds the host, because shipped mobile builds build it through +// `hold` before they subscribe. Delete both, and their allowlist entries, once +// MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes every client that still calls them. -import { defineMethod, type RpcContext } from '../core' +import { defineMethod } from '../core' import { - ensureStructuredHostInstalled, - requireStructuredCleanupHost, - requireStructuredHost + requireInstalledStructuredHost, + requireStructuredCleanupHost } from './structured-agent-session-gate' import { HoldParams } from './structured-agent-session-schemas' -const HOLD_CLEANUP_PREFIX = 'agentSession.hold' - -function holderKeyFor(ctx: RpcContext, holderId: string): string { - const client = ctx.clientId?.trim() || (ctx.clientKind ?? 'runtime') - return `${ctx.connectionId ?? 'local'}:${client}:${holderId}` -} - -function holdCleanupIdFor(sessionId: string, holderKey: string): string { - return `${HOLD_CLEANUP_PREFIX}:${holderKey}:${sessionId}` -} - export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ defineMethod({ name: 'agentSession.hold', params: HoldParams, - handler: async (params, ctx) => { - await ensureStructuredHostInstalled(ctx) - const host = requireStructuredHost(ctx) - const holderKey = holderKeyFor(ctx, params.holderId) - const registration = ctx.runtime.registerOwnedSubscriptionCleanup( - holdCleanupIdFor(params.sessionId, holderKey), - () => host.release(params.sessionId, holderKey), - ctx.connectionId - ) - try { - await host.hold(params.sessionId, holderKey) - } catch (error) { - registration.releaseIfCurrent() - throw error - } + handler: async (_params, ctx) => { + await requireInstalledStructuredHost(ctx) return { held: true as const } } }), defineMethod({ name: 'agentSession.release', params: HoldParams, - handler: async (params, ctx) => { - const host = requireStructuredCleanupHost(ctx) - const holderKey = holderKeyFor(ctx, params.holderId) - host.release(params.sessionId, holderKey) - // Retires the backstop too; its release is a no-op against a holder already gone. - ctx.runtime.cleanupSubscription(holdCleanupIdFor(params.sessionId, holderKey)) + handler: async (_params, ctx) => { + requireStructuredCleanupHost(ctx) return { released: true as const } } }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index b74b9cacfa6..19e93689577 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -1,22 +1,26 @@ // The options read surface: what a session reports about itself, and the // host's stored model catalog behind the picker. // -// `agentSession.options` serializes on the session because it asks the live -// provider what is selected. `agentSession.modelCatalog` deliberately does +// `agentSession.options` answers at rest from the record and the catalog, and +// asks the live provider only when one runs. `agentSession.modelCatalog` deliberately does // neither — answering from the host store is what lets a picker render while // an attach is still running. It is additive: an older host answers // `method_not_found` (or `forbidden` through the mobile allowlist gate), and // the client keeps its static seed. import { defineMethod } from '../core' -import { requireStructuredHost as requireHost } from './structured-agent-session-gate' +import { + requireInstalledStructuredHost, + requireStructuredHost as requireHost +} from './structured-agent-session-gate' import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ name: 'agentSession.options', params: OptionsParams, - handler: async (params, ctx) => requireHost(ctx).readOptions(params.sessionId) + handler: async (params, ctx) => + (await requireInstalledStructuredHost(ctx)).readOptions(params.sessionId) }), defineMethod({ name: 'agentSession.modelCatalog', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts index 24d9fa2aec4..4038fe9e40d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts @@ -12,6 +12,9 @@ import { AGENT_SESSION_WIRE_REFUSAL_CODES, + agentSessionRefusalFromReference, + isAgentSessionRefusalError, + refuseUnclassified, type AgentSessionWireRefusal, type AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' @@ -40,19 +43,25 @@ function wireRefusalCode(error: unknown): AgentSessionWireRefusalCode | null { return null } +const PRECOMMIT_REFUSAL_MESSAGE = 'Orca cannot open a structured agent chat for this workspace.' + function precommitRefusal(error: unknown): AgentSessionWireRefusal { + // A refusal the host raised keeps its situation; a bare code names none. + if (isAgentSessionRefusalError(error)) { + return agentSessionRefusalFromReference(error.refusal, PRECOMMIT_REFUSAL_MESSAGE) + } const code = wireRefusalCode(error) if (code) { - return { code, message: 'Orca cannot open a structured agent chat for this workspace.' } + return refuseUnclassified(code, PRECOMMIT_REFUSAL_MESSAGE) } const message = error instanceof Error ? error.message : String(error) // A code-less failure here is often a defect, not a policy answer; the refusal keeps the user - // moving, the log keeps the cause findable. + // moving, the log keeps the cause findable. Nothing names its situation, so it carries no reason. console.warn('[agent-session] create refused before it committed anything', error) - return { - code: UNCODED_PRECOMMIT_REFUSAL_CODE, - message: `Orca could not prepare a structured agent chat for this workspace: ${message}` - } + return refuseUnclassified( + UNCODED_PRECOMMIT_REFUSAL_CODE, + `Orca could not prepare a structured agent chat for this workspace: ${message}` + ) } /** diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index b3a70334f40..a48f897a005 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts @@ -64,20 +64,15 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ } }), defineMethod({ - // Reattach only, no send. No Orca surface calls it now — the desktop prompt's single action is - // resume-and-continue — but it is a PUBLISHED wire method, so dropping it is a wire removal an - // older or non-desktop client would meet as an unknown method. + // Reattach only, no send. Reattaching is nothing now — an agent starts only for work — so this + // answers that nothing was resumed. No Orca surface calls it, but it is a PUBLISHED wire + // method, so dropping it is a wire removal an older client would meet as an unknown method. name: 'agentSession.restartResume', params: RestartResumeParams, - handler: async (params, ctx) => { + handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) - const host = requireStructuredHost(ctx) - return { - results: await host.restartResume.resume( - params.sessionIds, - structuredCallerFor(ctx).callerKey - ) - } + requireStructuredHost(ctx) + return { results: [] } } }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 47f30a5030d..903f8d440af 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -9,7 +9,11 @@ // reads its own record and answers with that record's workspace and provider, so a client knowing // only a session id cannot aim the publication somewhere else. -import { isAgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' +import { + agentSessionRefusalFromReference, + isAgentSessionRefusalError, + isAgentSessionWireRefusalCode +} from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionReveal } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import { refuseAgentSessionMutation } from '../../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import { defineMethod } from '../core' @@ -37,13 +41,14 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ if (!isAgentSessionWireRefusalCode(code)) { throw error } - return refuseAgentSessionMutation({ - code, - message: + return refuseAgentSessionMutation( + agentSessionRefusalFromReference( + isAgentSessionRefusalError(error) ? error.refusal : { code }, code === 'structured_agent_session_unsupported' ? 'This host cannot open that chat.' : 'This chat is no longer on this host.' - }) + ) + ) } await ctx.runtime.publishStructuredAgentSessionTab({ workspaceId: revealed.workspaceId, 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 a9cf5cd7957..9a0f3902496 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 @@ -203,8 +203,7 @@ export function hostStub(): StructuredAgentSessionHost { subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) => statusFeed().subscribe(subscriber) ), - unsubscribe: vi.fn(), - release: vi.fn() + unsubscribe: vi.fn() }) return hostCalls as unknown as StructuredAgentSessionHost } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts index 0393cf216c8..473f6331372 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { AgentSessionHistoryPage, @@ -87,7 +87,6 @@ describe('turn item capability at the RPC boundary', () => { it.each(['snapshot', 'batch', 'reset'] as const)( 'downgrades the %s stream only for a legacy reader', async (type) => { - hostCalls.hold = vi.fn(async () => undefined) hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => { const base = { sessionId: SESSION, fence: 1 } if (type === 'batch') { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts new file mode 100644 index 00000000000..a31a8bc8d18 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts @@ -0,0 +1,91 @@ +// A transcript stream ends when its client says so: the host drops that subscriber, so nothing is +// derived or sent for it any more, and a sibling stream of the same session keeps going. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + createRestTestRig, + foundRestTestChat, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + restTestSend, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + connectionId: 'connection-1' +} + +let rig: RestTestRig +let dispatcher: RpcDispatcher + +function subscriberCount(): number { + return rig.host.collaboratorsForTests().subscribers.subscriberCountForTests(SESSION) +} + +async function stream(id: string, frames: RpcResponse[]): Promise { + await dispatcher.dispatchStreaming( + { id, authToken: 'token', method: 'agentSession.subscribe', params: { sessionId: SESSION } }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher writes RpcResponse JSON. + (raw) => frames.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) +} + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockImplementation( + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC gate reads only this one setting. + ({ experimentalStructuredNativeChat: true }) as ReturnType< + OrcaRuntimeService['getClientSettings'] + > + ) + dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +it('drops the subscriber a client unsubscribes, and keeps its sibling (U-02)', async () => { + await foundRestTestChat(rig) + const ended: RpcResponse[] = [] + const kept: RpcResponse[] = [] + await stream('frame-a', ended) + await stream('frame-b', kept) + expect(subscriberCount()).toBe(2) + + const replies: RpcResponse[] = [] + await dispatcher.dispatchStreaming( + { + id: 'frame-c', + authToken: 'token', + method: 'agentSession.unsubscribe', + params: { sessionId: SESSION, subscriptionId: 'frame-a' } + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher writes RpcResponse JSON. + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) + expect(replies[0]).toMatchObject({ ok: true }) + expect(subscriberCount()).toBe(1) + + const before = ended.length + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + await rig.host.send(CALLER, restTestSend('after the unsubscribe', fence)) + await vi.waitFor(() => expect(kept.length).toBeGreaterThan(1)) + // The ended stream's own `end` frame is all it gets. + expect(ended.slice(before).every((frame) => JSON.stringify(frame).includes('"end"'))).toBe(true) +}) 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 f97a458226a..a8378489c02 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -337,7 +337,9 @@ describe('capability gating', () => { ok: false, error: { message: expect.stringContaining('structured_agent_session_unsupported') } }) - expect(hostCalls[hostCall]).not.toHaveBeenCalled() + if (hostCall !== null) { + expect(hostCalls[hostCall]).not.toHaveBeenCalled() + } } ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index ebff420cb00..dc01a2fee8f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -5,6 +5,7 @@ // not exist rather than receiving the journal or mutation surface. Session-tab // inventory may expose only a metadata placeholder for an incapable mobile client. +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope' import type { z } from 'zod' import { @@ -18,6 +19,7 @@ import { import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, + requireInstalledStructuredHost as requireInstalledHost, requireStructuredCapability, requireStructuredCleanupHost, requireStructuredHost as requireHost, @@ -75,7 +77,9 @@ async function resolveClientSuppliedAttach(params: z.infer, await ensureHostInstalled(ctx) const host = requireHost(ctx) if (!host.supportsCreate(params.location, params.agent)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) } const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params const attachParams = { @@ -130,7 +134,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: CreateSupportParams, handler: async (params, ctx) => { if (!supportsStructuredSessions(ctx)) { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) } return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent) } @@ -141,7 +147,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ handler: async (params, ctx) => { requireStructuredCapability(ctx) if (params.envelope.expectedRuntimeFence !== null) { - throw new Error('agent_session_operation_invalid') + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) } // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so // a client can tell "nothing was created" from "the outcome is unknown". @@ -235,19 +243,20 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.handoffStatus', params: HandoffStatusParams, - handler: async (params, ctx) => requireHost(ctx).handoffStatus(params.sessionId) + handler: async (params, ctx) => + (await requireInstalledHost(ctx)).handoffStatus(params.sessionId) }), defineMethod({ name: 'agentSession.commands', params: OptionsParams, - handler: async (params, ctx) => requireHost(ctx).readCommands(params.sessionId) + handler: async (params, ctx) => (await requireInstalledHost(ctx)).readCommands(params.sessionId) }), defineMethod({ name: 'agentSession.history', params: HistoryParams, handler: async (params, ctx) => projectTurnItemHistory( - projectBackgroundTaskHistory(requireHost(ctx).history(params), ctx), + projectBackgroundTaskHistory(await (await requireInstalledHost(ctx)).history(params), ctx), ctx ) }), @@ -255,25 +264,17 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.subscribe', params: SubscribeParams, handler: async (params, ctx, emit) => { - const host = requireHost(ctx) + const host = await requireInstalledHost(ctx) const subscriptionId = subscriptionIdFor(ctx, params.sessionId) - // A live stream is a surface too: it keeps a session from being evicted while it is read and - // releases that retention when the transport dies without a word. - // - // Retain-only: reading history must never be what starts a provider process. Current clients - // explicitly hold every open surface before subscribing. - const streamHolder = `subscription:${subscriptionId}` + // A stream reads; it never keeps an agent alive or starts one. let dispose = (): void => {} - const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => { - dispose() - host.release(params.sessionId, streamHolder) - }) + const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => dispose()) if (stream.isClosed()) { return } - // The host emits the opening snapshot (or the missed batch) synchronously - // inside open(), so nothing between here and there can interleave. - dispose = host.subscribe({ + // Resolves once the conversation is open and the opening snapshot (or the missed batch) is + // emitted; a close that raced the open disposes what it bound. + dispose = await host.subscribe({ id: subscriptionId, sessionId: params.sessionId, emit: (event) => emit(projectTurnItemEvent(projectBackgroundTaskEvent(event, ctx), ctx)), @@ -281,14 +282,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }) if (stream.isClosed()) { dispose() - } else { - // Fire-and-forget, but never unhandled: a resume that refuses leaves the stream holding a - // readable session, which is exactly what the client sees anyway. - void host - .hold(params.sessionId, streamHolder, { resume: false }) - .catch((error: unknown) => - console.warn('[agent-session] stream hold failed', params.sessionId, error) - ) } } }), diff --git a/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts b/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts index 1a97223bc39..b376f2c80d9 100644 --- a/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-read-cursor.test.ts @@ -79,7 +79,7 @@ function read(cursor?: string, limit?: number) { }) } -function textsOf(result: ReturnType): string[] { +function textsOf(result: Awaited>): string[] { return result.transcript.messages.map((entry) => entry.blocks.map((block) => ('text' in block ? block.text : '')).join('') ) @@ -90,57 +90,57 @@ describe('the structured worker-read cursor over a mutating journal', () => { hostRef.current = null }) - it('refuses to resume when an already-delivered item was revised in place', () => { + it('refuses to resume when an already-delivered item was revised in place', async () => { // The `"hel"` / `"hello"` defect. The caller is handed a coalesced snapshot, resumes past it, // and the item is later revised at its original sequence — under the old anchor the resume was // accepted and that revision was never delivered to anyone. installJournal([message('i1', 'hel'), message('i2', 'second')]) - const first = read(undefined, 1) + const first = await read(undefined, 1) expect(textsOf(first)).toEqual(['hel']) installJournal([message('i1', 'hello world', 2), message('i2', 'second')]) - expect(() => read(first.cursor)).toThrow(/source changed/i) + await expect(read(first.cursor)).rejects.toThrow(/source changed/i) }) - it('refuses to resume when a resolved prompt inserts ahead of the caller position', () => { + it('refuses to resume when a resolved prompt inserts ahead of the caller position', async () => { // Duplication. A pending approval projects to null, so resolving it inserts a message in the // MIDDLE; the oldest item never moved, so the old anchor accepted a now-stale index and the // caller re-read content it already had. installJournal([message('i1', 'first'), approval('i2', false), message('i3', 'second')]) - const first = read(undefined, 2) + const first = await read(undefined, 2) expect(textsOf(first)).toEqual(['first', 'second']) installJournal([message('i1', 'first'), approval('i2', true, 2), message('i3', 'second')]) - expect(() => read(first.cursor)).toThrow(/source changed/i) + await expect(read(first.cursor)).rejects.toThrow(/source changed/i) }) - it('still resumes across a page boundary when only unread tail items change', () => { + it('still resumes across a page boundary when only unread tail items change', async () => { // The reason this is prefix-scoped and not whole-page: during an active turn the coalescer // revises the streaming item every 60ms. Fingerprinting the whole page would invalidate the // cursor continuously — a useless verb — while the worker is working. installJournal([message('i1', 'first'), message('i2', 'streaming')]) - const first = read(undefined, 1) + const first = await read(undefined, 1) expect(textsOf(first)).toEqual(['first']) installJournal([message('i1', 'first'), message('i2', 'streaming more', 7)]) - const second = read(first.cursor) + const second = await read(first.cursor) expect(textsOf(second)).toEqual(['streaming more']) }) - it('delivers every message exactly once when nothing below the cursor changes', () => { + it('delivers every message exactly once when nothing below the cursor changes', async () => { // The property the two refusals above protect: no omission, no duplication. installJournal([message('i1', 'a'), message('i2', 'b'), message('i3', 'c')]) - const first = read(undefined, 2) - const second = read(first.cursor, 2) + const first = await read(undefined, 2) + const second = await read(first.cursor, 2) expect([...textsOf(first), ...textsOf(second)]).toEqual(['a', 'b', 'c']) }) - it('still refuses when the window slides off the front', () => { + it('still refuses when the window slides off the front', async () => { // The case the old anchor DID catch, and which the prefix scoping must not lose: a slide // shifts every index. installJournal([message('i1', 'a'), message('i2', 'b')]) - const first = read(undefined, 1) + const first = await read(undefined, 1) installJournal([message('i2', 'b'), message('i3', 'c')]) - expect(() => read(first.cursor)).toThrow(/source changed/i) + await expect(read(first.cursor)).rejects.toThrow(/source changed/i) }) }) 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 e26788d5d46..59370934057 100644 --- a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts @@ -21,6 +21,7 @@ const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [ ['terminal.agentStatus', { terminal: 'term' }, false], ['terminal.rename', { terminal: 'term', title: null }, false], ['terminal.clearBuffer', { terminal: 'term' }, false], + ['terminal.resetInputModes', { terminal: 'term' }, false], ['terminal.send', { terminal: 'term', text: 'x' }, false], ['terminal.wait', { terminal: 'term', for: 'exit' }, false], ['terminal.create', {}, false], @@ -67,11 +68,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(35) + expect(TERMINAL_METHODS).toHaveLength(36) 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(35) + expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(36) for (const [name, params] of METHOD_CASES) { expect(() => schemaFor(name).parse(params), name).not.toThrow() } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index b28fed95098..59fd827f0ce 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -66,10 +66,11 @@ export async function sendTerminalStreamInput( const floorClaim: MobileInputFloorClaimHolder = { current: null } try { if (!clientId) { - const result = await runtime.sendTerminal(args.terminal, action) + const result = await runtime.sendTerminal(args.terminal, action, { inputKind: 'driving' }) return result.accepted ? 'delivered' : 'rejected' } const result = await runtime.sendTerminal(args.terminal, action, { + inputKind: 'driving', reserveWrite: (writePtyId) => { const claim = runtime.beginMobileInputFloor(writePtyId, clientId) if (!claim) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index a8e3f3cfa39..5202804c0d5 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -61,12 +61,20 @@ export async function publishLegacyBinaryInitialSnapshot( if (state.closed) { return } - // Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount. + // Why: missing model state (not blank snapshot text) signals a never-attached PTY; any renderer answer proves a pane, and the answer is null when the host's flag is unset or stale after a pane closed over a live PTY, which falls back to the mount wait. + const needsRendererScreen = + missingHeadlessStateBeforeMobileFit && serialized?.source !== 'renderer' + let rendererReady = + needsRendererScreen && + (await runtime.serializeRendererTerminalBuffer(ptyId, { scrollbackRows: 0 })) !== null + if (state.closed) { + return + } const mountRequested = - missingHeadlessStateBeforeMobileFit && - serialized?.source !== 'renderer' && + needsRendererScreen && + !rendererReady && (rendererMountRequestedBeforePty || runtime.requestRendererTerminalTabMount(params.terminal)) - if (missingHeadlessStateBeforeMobileFit && mountRequested) { + if (mountRequested) { // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. const mountWaitController = new AbortController() const abortMountWait = (): void => mountWaitController.abort() @@ -98,43 +106,49 @@ export async function publishLegacyBinaryInitialSnapshot( deadlineTimer.unref() } }) - const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) + rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) if (deadlineTimer) { clearTimeout(deadlineTimer) } if (state.closed || signal.aborted) { return } - if (rendererReady) { - read = await runtime.readTerminal(params.terminal) - const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( - runtime, - ptyId, - // The same frame, because this snapshot is published by the scrollback send below rather - // than by one of its own: the `resized` it used to name is a frame nothing here sends. - mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, scrollbackFrame) - ) - if (state.closed) { - return - } - if (stableRendererSnapshot?.data.length) { - serialized = stableRendererSnapshot - const trailingOutput = state.pendingOutput.flatMap((item) => { - const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) - const seq = item.meta?.seq - return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] - }) - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( - ptyId, - stableRendererSnapshot, - trailingOutput - ) - } - } else { + if (!rendererReady) { // Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes. state.lateRendererReadyPromise = rendererReadyPromise } } + if (rendererReady) { + read = await runtime.readTerminal(params.terminal) + const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( + runtime, + ptyId, + // The same frame, because this snapshot is published by the scrollback send below rather + // than by one of its own: the `resized` it used to name is a frame nothing here sends. + mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, scrollbackFrame) + ) + if (state.closed) { + return + } + // Why: a blank screen may be a parked pane that has not hydrated, and a seq-less screen has no + // seam against buffered output, so it is safe only when nothing is pending to replay twice. + if ( + stableRendererSnapshot?.data.length && + (typeof stableRendererSnapshot.seq === 'number' || state.pendingOutput.length === 0) + ) { + serialized = stableRendererSnapshot + const trailingOutput = state.pendingOutput.flatMap((item) => { + const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) + const seq = item.meta?.seq + return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] + }) + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId, + stableRendererSnapshot, + trailingOutput + ) + } + } let initialOutputOverflowed = false if (state.pendingOutputOverflowed) { state.pendingOutput.splice(0) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index 52c1063b0cc..68afc1e8d19 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -121,5 +121,12 @@ export const TERMINAL_QUERY_METHODS = [ handler: async (params, { runtime }) => ({ clear: await runtime.clearTerminalBuffer(params.terminal) }) + }), + defineMethod({ + name: 'terminal.resetInputModes', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + reset: await runtime.resetTerminalInputModes(params.terminal) + }) }) ] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 99c01d902a4..ca7ac0aeb75 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -210,6 +210,7 @@ export const TERMINAL_SEND_METHODS = [ try { result = useSettledAgentPrompt ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { + inputKind: 'driving', beforeWrite, signal, ...(orchestrationMutation @@ -234,6 +235,8 @@ export const TERMINAL_SEND_METHODS = [ { beforeWrite, signal, + // Why: a wire write carries no provenance beyond a client's own query reply. + inputKind: params.inputKind === 'query-reply' ? 'query-reply' : 'driving', ...(reserveWrite ? { reserveWrite } : {}), ...(params.inputKind !== 'query-reply' && mobileFloorClientId ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } diff --git a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts index 29254fbcf3a..824513dd999 100644 --- a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts +++ b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts @@ -45,6 +45,7 @@ describe('terminal agent prompt send RPC', () => { expect(response.ok).toBe(true) expect(runtime.isTerminalRunningSettledPromptAgent).toHaveBeenCalledWith('terminal-1') expect(sendTerminalAgentPrompt).toHaveBeenCalledWith('terminal-1', 'review this change', { + inputKind: 'driving', beforeWrite: undefined, signal: undefined }) @@ -81,7 +82,7 @@ describe('terminal agent prompt send RPC', () => { expect(sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'echo x', enter: true, interrupt: false }, - { beforeWrite: undefined, signal: undefined } + { inputKind: 'driving', beforeWrite: undefined, signal: undefined } ) expect(sendTerminalAgentPrompt).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts index 3dc1f6b2918..f93b772994e 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts @@ -241,11 +241,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'still interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'still interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) handlers.get(16)?.( @@ -434,11 +438,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-8', { - text: 'remote-still-interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-8', + { + text: 'remote-still-interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) const frameCountBeforeAck = binaryFrames.length diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts index 03327859c62..57abea846ae 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts @@ -413,11 +413,15 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'still interactive\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'still interactive\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) binaryFrames.splice(0) diff --git a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts index 45ff2d77b8c..562181b2752 100644 --- a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts @@ -206,11 +206,15 @@ describe('terminal multiplex RPC', () => { ) ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'ls\r', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false) @@ -248,11 +252,15 @@ describe('terminal multiplex RPC', () => { ) } await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenLastCalledWith('terminal-1', { - text: 'retry', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenLastCalledWith( + 'terminal-1', + { + text: 'retry', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) dataListenerRef.current?.('a') diff --git a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts index b7fb20b2f73..ae2298a20dc 100644 --- a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts @@ -327,11 +327,15 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'echo one\necho two\r\n', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'echo one\necho two\r\n', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) runtime.cleanupSubscription('terminal-multiplex:conn-byte-preserving') @@ -403,11 +407,15 @@ describe('terminal multiplex RPC', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'printf a\nprintf b\r\n', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { + text: 'printf a\nprintf b\r\n', + enter: false, + interrupt: false + }, + { inputKind: 'driving' } + ) ) runtime.cleanupSubscription('terminal-1:desktop-1') diff --git a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts index ef6087923bb..eecd7b03859 100644 --- a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts @@ -309,7 +309,11 @@ describe('terminal multiplex RPC', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'x', enter: false, interrupt: false }, - { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + { + inputKind: 'driving', + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) ) expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index e597f5e5b13..214a5a2814e 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -347,7 +347,11 @@ describe('terminal output batching', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: 'ls\r', enter: false, interrupt: false }, - { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + { + inputKind: 'driving', + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) ) expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index e9fb481aae0..9aa55eb823e 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -232,6 +232,7 @@ describe('terminal send RPC', () => { interrupt: false }, { + inputKind: 'driving', beforeWrite: undefined, reserveWrite: expect.any(Function), afterWrite: expect.any(Function) @@ -370,7 +371,7 @@ describe('terminal send RPC', () => { expect(runtime.sendTerminal).toHaveBeenCalledWith( 'terminal-1', { text: '\x1b[3;4R', enter: false, interrupt: false }, - { beforeWrite: undefined } + { beforeWrite: undefined, inputKind: 'query-reply' } ) expect(runtime.mobileTookFloor).not.toHaveBeenCalled() }) @@ -570,7 +571,7 @@ describe('terminal send RPC', () => { enter: false, interrupt: false }, - { beforeWrite: undefined } + { inputKind: 'driving', beforeWrite: undefined } ) }) @@ -653,7 +654,7 @@ describe('terminal send RPC', () => { enter: true, interrupt: false }, - { beforeWrite: expect.any(Function) } + { inputKind: 'driving', beforeWrite: expect.any(Function) } ) }) diff --git a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts index c64747616cf..6dd443f1402 100644 --- a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts @@ -11,6 +11,11 @@ import { RpcDispatcher } from './dispatcher' import { TERMINAL_METHODS } from './methods/terminal' import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' +function asRuntime(double: Record): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal.subscribe reads only the members the double provides. + return double as unknown as OrcaRuntimeService +} + const request: RpcRequest = { id: 'req-1', authToken: 'tok', @@ -97,7 +102,7 @@ describe('terminal subscribe mount replay', () => { let generation = 1 let headlessPresent = false let serializeCalls = 0 - const runtime = { + const runtime = asRuntime({ getRuntimeId: () => 'test-runtime', subscribeToPtyExit: vi.fn(() => vi.fn()), resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), @@ -110,9 +115,9 @@ describe('terminal subscribe mount replay', () => { waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { return generation > afterGeneration }), + // The phone fit's redraw creates suffix-only headless state. handleMobileSubscribe: vi.fn(async () => { headlessPresent = true - generation = 2 return true }), handleMobileUnsubscribe: vi.fn(), @@ -126,12 +131,15 @@ describe('terminal subscribe mount replay', () => { } return { data: 'raced idle prompt $ ', cols: 80, rows: 24, seq: 5 } }), - serializeRendererTerminalBuffer: vi.fn(async () => ({ - data: 'raced idle prompt $ ', - cols: 80, - rows: 24, - seq: 5 - })), + // Baseline race: the pane settles between the attachment answer and the wait, so the wait + // must still count that settle against the pre-fit generation. + serializeRendererTerminalBuffer: vi + .fn() + .mockImplementationOnce(async () => { + generation = 2 + return null + }) + .mockResolvedValue({ data: 'raced idle prompt $ ', cols: 80, rows: 24, seq: 5 }), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), @@ -142,7 +150,7 @@ describe('terminal subscribe mount replay', () => { registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), cleanupSubscription: vi.fn(registry.cleanupSubscription), waitForTerminal: vi.fn(() => new Promise(() => {})) - } as unknown as OrcaRuntimeService + }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { @@ -286,11 +294,8 @@ describe('terminal subscribe mount replay', () => { const registry = createSubscriptionRegistryDouble() let generation = 0 let headlessPresent = false - const requestRendererTerminalTabMount = vi.fn(() => { - generation = 1 - return true - }) - const runtime = { + const requestRendererTerminalTabMount = vi.fn(() => true) + const runtime = asRuntime({ getRuntimeId: () => 'test-runtime', subscribeToPtyExit: vi.fn(() => vi.fn()), resolveLeafForHandle: vi.fn().mockReturnValue(null), @@ -320,11 +325,15 @@ describe('terminal subscribe mount replay', () => { rows: 24, seq: 1 }), - serializeRendererTerminalBuffer: vi.fn().mockResolvedValue({ - data: 'late leaf prompt $ ', - cols: 80, - rows: 24 - }), + // Baseline race: the pre-PTY mount settles between the attachment answer and the wait, so the + // wait must still count that settle against the pre-mount generation. + serializeRendererTerminalBuffer: vi + .fn() + .mockImplementationOnce(async () => { + generation = 1 + return null + }) + .mockResolvedValue({ data: 'late leaf prompt $ ', cols: 80, rows: 24 }), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), @@ -335,7 +344,7 @@ describe('terminal subscribe mount replay', () => { registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), cleanupSubscription: vi.fn(registry.cleanupSubscription), waitForTerminal: vi.fn(() => new Promise(() => {})) - } as unknown as OrcaRuntimeService + }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { diff --git a/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts b/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts new file mode 100644 index 00000000000..56720ff5d48 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts @@ -0,0 +1,370 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' + +const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +function asRuntime(double: Record): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: terminal.subscribe reads only the members the double provides. + return double as unknown as OrcaRuntimeService +} + +type PaneDouble = { + /** What the desktop renderer's serializer answers; null when no pane is registered. */ + rendererScreen: () => string | null + /** How long each renderer serialize takes; the IPC answers null after 750 ms when no pane replies. */ + rendererAnswerDelayMs?: number + /** PTY output high-water; a pane printing continuously advances it on every read. */ + outputSequence?: () => number + /** Renderer-ordered seq; null when the pane does not order output itself. */ + rendererSeq?: number | null + /** What the preference order serves before any renderer probe. */ + chosenScreen?: string + /** Live output that arrives while the subscription is still buffering. */ + pendingOutput?: string + /** Output offset at the end of the pending chunk. */ + pendingOutputSeq?: number + waitForRendererTerminalSerializer: OrcaRuntimeService['waitForRendererTerminalSerializer'] +} + +function subscribeMobile(pane: PaneDouble) { + const binaryFrames: Uint8Array[] = [] + const registry = createSubscriptionRegistryDouble() + let emitData: (data: string, meta: { seq: number; rawLength: number }) => void = () => {} + const runtime = { + getRuntimeId: () => 'test-runtime', + subscribeToPtyExit: vi.fn(() => vi.fn()), + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + // A daemon PTY reattached after a desktop restart has no headless model yet. + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + getPtyOutputSequence: vi.fn(pane.outputSequence ?? (() => 4)), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(pane.waitForRendererTerminalSerializer), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId: string, listener: typeof emitData) => { + emitData = listener + return vi.fn() + }), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + // The restored provider snapshot wins the preference order over the live renderer. + serializeTerminalBuffer: vi.fn(async () => { + if (pane.pendingOutput) { + emitData(pane.pendingOutput, { + seq: pane.pendingOutputSeq ?? 3, + rawLength: pane.pendingOutput.length + }) + } + return { + data: pane.chosenScreen ?? 'restored provider history', + cols: 80, + rows: 24, + seq: 2 + } + }), + serializeRendererTerminalBuffer: vi.fn(async () => { + if (pane.rendererAnswerDelayMs) { + await new Promise((resolve) => setTimeout(resolve, pane.rendererAnswerDelayMs)) + } + const screen = pane.rendererScreen() + const seq = pane.rendererSeq === undefined ? 4 : pane.rendererSeq + return screen === null + ? null + : { + data: screen, + cols: 80, + rows: 24, + ...(seq === null ? {} : { seq }), + source: 'renderer' as const + } + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn(registry.registerSubscriptionCleanup), + registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), + cleanupSubscription: vi.fn(registry.cleanupSubscription), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } + const dispatcher = new RpcDispatcher({ runtime: asRuntime(runtime), methods: TERMINAL_METHODS }) + const done = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + const snapshotText = (): string => + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + const outputText = (): string => + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + const close = async (): Promise => { + runtime.cleanupSubscription('terminal-1:phone-1') + await done + } + return { runtime, snapshotText, outputText, close } +} + +describe('terminal subscribe for a pane the desktop already has mounted', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('adopts the live renderer screen over the chosen snapshot without waiting out the mount deadline', async () => { + // The renderer drops a mount request for a mounted tab, so no newer serializer settle ever arrives. + const subscription = subscribeMobile({ + chosenScreen: 'suffix-only redraw', + rendererScreen: () => 'live desktop prompt $ ', + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.snapshotText()).toContain('live desktop prompt $ ') + expect(subscription.snapshotText()).not.toContain('suffix-only redraw') + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(subscription.runtime.waitForRendererTerminalSerializer).not.toHaveBeenCalled() + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).toHaveBeenCalledWith('pty-1', expect.objectContaining({ data: 'live desktop prompt $ ' }), []) + await subscription.close() + }) + + it('adopts a renderer-ordered screen even while output is pending', async () => { + // The screen's seq (4) is an exact seam inside the pending chunk (offsets 3..5), so only `z` replays. + const subscription = subscribeMobile({ + rendererScreen: () => 'ordered desktop prompt $ ', + pendingOutput: 'Xz', + pendingOutputSeq: 5, + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(subscription.snapshotText()).toContain('ordered desktop prompt $ ') + expect(subscription.snapshotText()).not.toContain('restored provider history') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).toHaveBeenCalledWith( + 'pty-1', + expect.objectContaining({ data: 'ordered desktop prompt $ ', seq: 4 }), + [{ data: 'z', seq: 5 }] + ) + expect(subscription.outputText()).toBe('z') + await subscription.close() + }) + + it('answers at once for a mounted pane whose screen is still empty', async () => { + // A fresh shell that has printed nothing: the serializer is registered but its screen is blank. + const subscription = subscribeMobile({ + rendererScreen: () => '', + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + // A blank renderer must not erase history the chosen snapshot already carries. + expect(subscription.snapshotText()).toContain('restored provider history') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).not.toHaveBeenCalled() + await subscription.close() + }) + + it('answers at once for a mounted pane whose output never settles', async () => { + // A desktop agent printing continuously: every renderer serialize races a new byte. + let sequence = 4 + const subscription = subscribeMobile({ + rendererScreen: () => `agent frame ${sequence}`, + outputSequence: () => (sequence += 1), + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(subscription.runtime.waitForRendererTerminalSerializer).not.toHaveBeenCalled() + // An unsettled screen has no exact seam against buffered output, so the chosen snapshot goes out. + expect(subscription.snapshotText()).toContain('restored provider history') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).not.toHaveBeenCalled() + await subscription.close() + }) + + it('keeps the chosen snapshot for a seq-less mounted screen while output is pending', async () => { + // Without a seq, the buffered chunk would replay on top of a screen that already holds it. + const subscription = subscribeMobile({ + rendererScreen: () => 'unordered desktop prompt $ ', + rendererSeq: null, + pendingOutput: 'pending byte', + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(subscription.snapshotText()).toContain('restored provider history') + expect(subscription.snapshotText()).not.toContain('unordered desktop prompt $ ') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).not.toHaveBeenCalled() + await subscription.close() + }) + + it('adopts a seq-less mounted screen when no output is pending', async () => { + // Right after a deferred cold restore the pane is not renderer-ordered yet; nothing can replay twice. + const subscription = subscribeMobile({ + rendererScreen: () => 'unordered desktop prompt $ ', + rendererSeq: null, + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(subscription.snapshotText()).toContain('unordered desktop prompt $ ') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).toHaveBeenCalledWith( + 'pty-1', + expect.objectContaining({ data: 'unordered desktop prompt $ ' }), + [] + ) + await subscription.close() + }) + + it('still requests the mount and waits for its settle when no pane is registered', async () => { + let mounted = false + const subscription = subscribeMobile({ + rendererScreen: () => (mounted ? 'mounted idle prompt $ ' : null), + waitForRendererTerminalSerializer: (_ptyId, afterGeneration) => { + expect(afterGeneration).toBe(1) + return new Promise((resolve) => { + setTimeout(() => { + mounted = true + resolve(true) + }, 500) + }) + } + }) + + await vi.advanceTimersByTimeAsync(100) + expect(subscription.runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + expect(subscription.snapshotText()).toBe('') + // No pane is registered, so nothing may spend a terminal read (which can reach the provider) before the settle. + expect(subscription.runtime.readTerminal).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(400) + expect(subscription.snapshotText()).toContain('mounted idle prompt $ ') + expect(subscription.snapshotText()).not.toContain('restored provider history') + await subscription.close() + }) + + it('keeps the chosen snapshot after the mount wait when a seq-less screen meets pending output', async () => { + let mounted = false + const subscription = subscribeMobile({ + rendererScreen: () => (mounted ? 'unordered mounted prompt $ ' : null), + rendererSeq: null, + pendingOutput: 'pending byte', + waitForRendererTerminalSerializer: () => + new Promise((resolve) => { + setTimeout(() => { + mounted = true + resolve(true) + }, 500) + }) + }) + + await vi.advanceTimersByTimeAsync(500) + + expect(subscription.runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + expect(subscription.snapshotText()).toContain('restored provider history') + expect( + subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery + ).not.toHaveBeenCalled() + await subscription.close() + }) + + it('falls back to the mount wait within one serialize when the flag outlives its pane', async () => { + // Nothing clears the flag when a tab closes over a live PTY; the IPC then answers null at its + // 750 ms deadline while a busy PTY moves output under every attempt. + let sequence = 4 + const subscription = subscribeMobile({ + rendererScreen: () => null, + rendererAnswerDelayMs: 750, + outputSequence: () => (sequence += 1), + waitForRendererTerminalSerializer: (_ptyId, _after, _timeout, signal) => + new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + }) + + await vi.advanceTimersByTimeAsync(750) + expect(subscription.runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + await vi.advanceTimersByTimeAsync(2_999) + expect(subscription.snapshotText()).toBe('') + + // Worst case: one serialize plus the 3 s mount deadline. + await vi.advanceTimersByTimeAsync(1) + expect(subscription.snapshotText()).toContain('restored provider history') + expect(subscription.runtime.serializeRendererTerminalBuffer).toHaveBeenCalledTimes(1) + await subscription.close() + }) +}) diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 54aae2321ad..60c1e18cf10 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -12,6 +12,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimePtyController = { claimStablePaneCreate?(args: { @@ -93,9 +94,13 @@ export type RuntimePtyController = { stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> - write(ptyId: string, data: string): boolean + write(ptyId: string, data: string, inputKind: TerminalInputKind): boolean /** Three-valued settlement; local providers settle synchronously. */ - writeWithSettlement?(ptyId: string, data: string): WriteSettlement | Promise + writeWithSettlement?( + ptyId: string, + data: string, + inputKind: TerminalInputKind + ): WriteSettlement | Promise /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ @@ -106,7 +111,6 @@ export type RuntimePtyController = { ptyId: string, opts?: { keepHistory?: boolean; deadlineMs?: number } ): Promise - markReversibleStops?(ptyIds: readonly string[]): () => void /** Durably records a kill order for an explicit close's unconfirmed stop, replayed when its SSH * host reconnects. True only when an order was written; local PTYs have no later host to ask. */ recordUnconfirmedStop?(ptyId: string): boolean @@ -120,6 +124,7 @@ export type RuntimePtyController = { confirmShellForeground?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise + resetInputModes?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean // Why: exact-id mobile polls should not enumerate every local and SSH PTY. hasPty?(ptyId: string): boolean | null 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 ce67fb94aff..a543a8da45d 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 @@ -230,6 +230,8 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.history', 'agentSession.subscribe', 'agentSession.unsubscribe', + // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the + // mobile builds that still call them. 'agentSession.hold', 'agentSession.release', 'nativeChat.readSession', diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 0dcf355bfe0..fa0e7a0afa0 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -16,6 +16,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeTerminalSend } from '../../shared/runtime-terminal-contracts' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import type { WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' @@ -203,7 +204,9 @@ export type RuntimeProviderSnapshotReadOptions = { } /** Agent-prompt writes add the correlation inputs a queued-acceptance receipt needs. */ -export type RuntimeAgentPromptWriteOptions = RuntimeTerminalWriteOptions & { +export type RuntimeAgentPromptWriteOptions = Omit & { + /** `launch` for the prompt an agent starts with; `driving` for any prompt sent to a running one. */ + inputKind: Exclude /** Raw prompt text for submit scheduling; not written, only used for line-aware delays. */ promptForSchedule?: string /** See buildAgentPromptPasteBytes. */ diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index 24df1cc1cbf..51e56d0b508 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -5,6 +5,7 @@ import type { RuntimeTerminalOrphanAdoptionResult } from '../../shared/runtime-types' import { makePaneKey } from '../../shared/stable-pane-id' +import { hasClosedTerminalTabRecord } from '../../shared/closed-terminal-tab-tombstones' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' @@ -185,7 +186,11 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: { ) { throw new Error('terminal_orphan_surface_occupied') } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + // Why the close record too: it outlives a host restart, which the client's retirement proofs do not. + if ( + session.terminalSurfaceTombstonesByPaneKey?.[paneKey] || + hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, claim.tabId) + ) { throw new Error('terminal_orphan_surface_retired') } for (const snapshot of ports.getMobileSnapshots()) { diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 7ff6ba4a7f3..10e94f3747f 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -1,8 +1,10 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' import type { TuiAgent } from '../../shared/tui-agent' import { iterateTerminalInputChunks } from '../../shared/terminal-input' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimeTerminalWriteOptions = { + inputKind: TerminalInputKind signal?: AbortSignal beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void @@ -12,7 +14,7 @@ export type RuntimeTerminalWriteOptions = { export class RuntimeTerminalWriter { constructor( - private readonly write: (ptyId: string, data: string) => boolean, + private readonly write: (ptyId: string, data: string, inputKind: TerminalInputKind) => boolean, private readonly getWriteHostPlatform: (ptyId: string) => NodeJS.Platform = () => process.platform, private readonly getAgent: (ptyId: string) => TuiAgent | null = () => null @@ -22,7 +24,7 @@ export class RuntimeTerminalWriter { ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. @@ -54,7 +56,7 @@ export class RuntimeTerminalWriter { throw error } options.reserveWrite?.(ptyId) - if (!this.write(ptyId, suffix)) { + if (!this.write(ptyId, suffix, options.inputKind)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } await options.afterWrite?.(ptyId) @@ -65,7 +67,7 @@ export class RuntimeTerminalWriter { } await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, payload)) { + if (!this.write(ptyId, payload, options.inputKind)) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) @@ -74,14 +76,14 @@ export class RuntimeTerminalWriter { async writeChunks( ptyId: string, text: string, - options: RuntimeTerminalWriteOptions = {} + options: RuntimeTerminalWriteOptions ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, chunk.value)) { + if (!this.write(ptyId, chunk.value, options.inputKind)) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index c7472b914d2..e45425b8dd3 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -107,6 +107,15 @@ export class RuntimeWorkspaceSessionController { return hostId ? (this.deps.getStore()?.getWorkspaceSession?.(hostId) ?? null) : null } + /** The session only when the worktree's own host partition owns it, not a rotated-owner fallback. */ + getOwnPartition(worktreeId: string): WorkspaceSessionState | null { + const store = this.deps.getStore() + const hostId = store ? this.getPreferredHostId(worktreeId, store) : null + return hostId && hostId === this.tryGetHostId(worktreeId) + ? (store?.getWorkspaceSession?.(hostId) ?? null) + : null + } + set(worktreeId: string, session: WorkspaceSessionState): void { this.deps.getStore()?.setWorkspaceSession?.(session, this.getHostId(worktreeId)) } 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 75cbf2e6231..7b22a45d09d 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -2,6 +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 { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import type { @@ -128,7 +130,13 @@ describe('a request that failed reads as failed through the feed, the ingest and fence: 1, handoverRecorded: true }) - await journal.rejectQueuedSubmissions(1, 'Claude is not signed in.') + await journal.rejectQueuedSubmissions( + 1, + agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'rejection', + agentName: 'Claude' + }) + ) const summary = publishedSummary(journal) expect(summary).toMatchObject({ status: 'idle', turnOutcome: 'failure', latestPrompt: 'hello' }) @@ -205,7 +213,10 @@ describe('a request that failed reads as failed through the feed, the ingest and fence: 1, handoverRecorded: true }) - await journal.rejectQueuedSubmissions(1, 'provider_cancelled_before_start') + await journal.rejectQueuedSubmissions( + 1, + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + ) expect(publishedSummary(journal)).toMatchObject({ status: null }) expect(ingest(publishedSummary(journal)).ps).toBeUndefined() diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index a203d170966..10f335a4b05 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -1,3 +1,4 @@ +import { markQoderWorkspaceTrusted } from '../qoder/workspace-trust' import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority' import { tuiAgentToAgentKind } from '../../shared/agent-kind' import type { Repo } from '../../shared/repo-types' @@ -195,7 +196,9 @@ export async function markLocalWorktreeTrusted( return } try { - if (preset === 'cursor') { + if (preset === 'qoder') { + markQoderWorkspaceTrusted(workspacePath) + } else if (preset === 'cursor') { markCursorWorkspaceTrusted(workspacePath) } else if (preset === 'copilot') { markCopilotFolderTrusted(workspacePath) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 7208e0d3bde..779f16ec6a4 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -4,6 +4,7 @@ import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-sca import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { WorktreeStartupDraftPaste, WorktreeStartupFollowup @@ -19,7 +20,7 @@ export type WorktreeStartupReadinessHost = { hasChildProcesses?: (ptyId: string) => Promise subscribeToData: (ptyId: string, listener: (data: string) => void) => () => void readRecentOutput: (ptyId: string) => string | undefined - write: (ptyId: string, data: string) => void + write: (ptyId: string, data: string, inputKind: TerminalInputKind) => void } export function pasteWorktreeStartupDraftWhenReady( @@ -33,7 +34,7 @@ export function pasteWorktreeStartupDraftWhenReady( console.warn('[worktree-create] agent did not become ready for draft paste') return } - host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`) + host.write(ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}`, 'launch') }) .catch((error) => console.warn('[worktree-create] failed to paste startup draft:', error)) } @@ -49,7 +50,7 @@ export function sendWorktreeStartupFollowupWhenReady( console.warn('[worktree-create] agent did not become ready for follow-up prompt') return } - host.write(ptyId, `${followup.prompt}\r`) + host.write(ptyId, `${followup.prompt}\r`, 'launch') }) .catch((error) => console.warn('[worktree-create] failed to send startup follow-up prompt:', error) diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index 9afb2723a88..668b7f21890 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -3,8 +3,8 @@ * * Extracted from `stopStructuredWorker` so that orchestration settlement and worktree teardown * close a session the SAME way rather than one of them inventing a shorter version. Everything - * dispatch-shaped — dropping the hold, the redrive subscription and the parked mail — stays with - * the caller that has a dispatch; this is only the child. + * dispatch-shaped — dropping the redrive subscription, the registry entry and the parked mail — + * stays with the caller that has a dispatch; this is only the child. * * `host.close` returns void and keeps a failed close indexed for retry, so the only settlement * evidence is the observation AFTER it: a session the host no longer holds and whose lease is no @@ -15,7 +15,10 @@ import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wi import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from './orca-runtime' import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' -import { observeStructuredWorker } from './structured-worker-authority' +import { + observeStructuredWorker, + structuredSessionCloseSettled +} from './structured-worker-authority' export type StructuredAgentSessionCloseOutcome = { stopped: boolean @@ -32,8 +35,8 @@ export type StructuredAgentSessionCloseOptions = { /** * Runs after the close is issued and BEFORE the proof is read. * - * Not after: an unsettled close returns early, so a dispatch that released its hold there would - * keep the child un-evictable for the life of the app. Every settlement has to reach it. + * Not after: an unsettled close returns early, so a dispatch released there would keep its + * redrive subscription nudging a session no dispatch owns. Every settlement has to reach it. */ afterClose?: () => void /** @@ -86,8 +89,8 @@ export async function closeStructuredAgentSessionChild( } } options.afterClose?.() - const observation = observeStructuredWorker({ sessionId }) - if (observation.status !== 'exited') { + if (!structuredSessionCloseSettled(sessionId)) { + const observation = observeStructuredWorker({ sessionId }) await restorePersistedTabVisibility(host, sessionId, restoreTabIfCloseFails) return { stopped: false, @@ -137,7 +140,7 @@ async function restorePersistedTabVisibility( sessionId: string, tabId: string | null ): Promise { - if (tabId === null || observeStructuredWorker({ sessionId }).status === 'exited') { + if (tabId === null || structuredSessionCloseSettled(sessionId)) { return } try { 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 new file mode 100644 index 00000000000..fc06a2a782b --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -0,0 +1,217 @@ +// 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, +// journal and Codex adapter; only the Codex child is fake, keeping Codex 0.157's +// turn bookkeeping. + +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 { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +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' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + 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 { + 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', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let turns: ReturnType +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +async function stop(turnId: string): Promise { + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId }), + turnId + }) + if (!stopped.ok) { + throw new Error(JSON.stringify(stopped.refusal)) + } +} + +async function settled(): Promise<{ + submissions: readonly AgentJournalSubmission[] + owesWork: boolean +}> { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + return { + submissions: snapshot.submissions, + // The shared rule every surface reads "working" from. + owesWork: owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence) + } +} + +function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { + const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) + return submission?.dispatchState === 'rejected' + ? classifyDispatchRejection(submission).category + : submission?.dispatchState +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) + answers = 0 + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} + ) => { + handlers = connectionHandlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method, params) => { + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/interrupt') { + return turns.routes['turn/interrupt'](params) + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, attachParams) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + fence = attached.value.fence +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a Codex send its turn ended without taking it', () => { + it('is withdrawn by a Stop before any echo, and nothing reads as working after', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + await stop('turn-1') + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).not.toBe('pending') + ) + const after = await settled() + expect(verdictOf(after.submissions, sent)).toBe('withdrawn') + expect(after.owesWork).toBe(false) + + // Codex echoing it late changes nothing: a settled answer stands. + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: sent, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + }) + + it('withdraws a follow-up Codex steered into the turn a Stop ends, with no working latch', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const followUp = await send('and check the tests') + // Steered: Codex answers with the running turn, and fires no second turn/started. + await vi.waitFor(() => expect(answers).toBe(2)) + + await stop('turn-1') + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, followUp)).not.toBe('pending') + ) + const after = await settled() + expect(verdictOf(after.submissions, opening)).toBe('accepted') + expect(verdictOf(after.submissions, followUp)).toBe('withdrawn') + expect(after.owesWork).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index d81170ee075..1b7136a996d 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -289,10 +289,10 @@ function textOf(item: AgentJournalRenderItem): string { } /** The durable submission row, which settlement rewrites after the send returns. */ -function submissionOf(clientMessageId: string): AgentJournalSubmission | undefined { - return getStructuredAgentSessionHost() - ?.journalSnapshot(SESSION) - .submissions.find((entry) => entry.clientMessageId === clientMessageId) +async function submissionOf(clientMessageId: string): Promise { + return (await getStructuredAgentSessionHost()?.journalSnapshot(SESSION))?.submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) } async function historyPage( @@ -495,8 +495,8 @@ describe('a structured codex session over agentSession.*', () => { // The echo is the first item of this turn, so the settled key is ordinal 0 — // minted by the same `identityFor` a history replay computes with, rather // than guessed from the turn/start response. - await vi.waitFor(() => - expect(submissionOf(sent.clientMessageId)).toMatchObject({ + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'accepted', providerItemId: `codex:${THREAD}:${TURN}:0` }) @@ -584,8 +584,8 @@ describe('a structured codex session over agentSession.*', () => { expect(itemsOf(stream).filter((item) => textOf(item) === 'list files')).toHaveLength(1) // Settled from the echo's own journal identity, so it is by construction the // key a replay recomputes for this row. - await vi.waitFor(() => - expect(submissionOf(sent.clientMessageId)).toMatchObject({ + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'accepted', providerItemId: `codex:${THREAD}:${TURN}:0` }) diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 0b2c8bd4f4b..3da0c6f203d 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -32,7 +32,7 @@ describe('structured session runtime provider-exit wiring', () => { } }) - it('reacquires through the production callback and accepts a distinct next message', async () => { + it('does not respawn after a provider exit, and the next message starts a new child', async () => { root = await mkdtemp(join(tmpdir(), 'orca-runtime-provider-exit-')) operations = 0 const connections: { @@ -98,16 +98,17 @@ describe('structured session runtime provider-exit wiring', () => { JSON.stringify({ refusal: attached.refusal, connections: connections.length }) ) } - await host.hold(SESSION, 'desktop-chat:1') - const exitedFence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const exited = connections[0] exited?.handlers.onExit?.(new Error('scripted provider exit')) - - await vi.waitFor(() => expect(connections).toHaveLength(2)) + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) + // Nothing restarts the child on its own; the exit is shown and the chat waits for a send. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(connections).toHaveLength(1) const recoveredFence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence - expect(recoveredFence).toBeGreaterThan(exitedFence) if (recoveredFence === undefined) { - throw new Error('recovered lease omitted its fence') + throw new Error('released lease omitted its fence') } const body = hostTestMessage('continue with a distinct message') const envelope = { @@ -128,7 +129,8 @@ describe('structured session runtime provider-exit wiring', () => { await expect( host.send({ callerKey: 'runtime-test' }, { envelope, body }) ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // The send answers at acceptance; the delivery loop hands it to the reacquired child after. + // The send answers at acceptance; the delivery loop starts a new child and hands it over. + await vi.waitFor(() => expect(connections).toHaveLength(2)) await vi.waitFor(() => expect(turn).toBe(1)) }) @@ -200,7 +202,6 @@ describe('structured session runtime provider-exit wiring', () => { JSON.stringify({ refusal: attached.refusal, connections: connections.length }) ) } - await host.hold(SESSION, 'desktop-chat:requested-close') await stopStructuredAgentSessionRuntime() await new Promise((resolve) => setImmediate(resolve)) @@ -224,7 +225,7 @@ describe('structured session runtime provider-exit wiring', () => { readProcessStartTime: async () => 1_700_000_000_000 }) await restarted.restoreReadableSessions() - const history = restarted.history({ sessionId: SESSION, direction: 'tail' }) + const history = await restarted.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false) expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual({ provider: 'codex', @@ -232,7 +233,7 @@ describe('structured session runtime provider-exit wiring', () => { }) }) - it('waits for an in-flight recovery before tearing down the runtime', async () => { + it('waits for a start a send began before tearing down the runtime', async () => { root = await mkdtemp(join(tmpdir(), 'orca-runtime-recovery-shutdown-')) let releaseRecovery!: () => void const recoveryReleased = new Promise((resolve) => { @@ -301,8 +302,28 @@ describe('structured session runtime provider-exit wiring', () => { attachParams.envelope.clientOperationId = operationId() const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) expect(attached.ok).toBe(true) - await host.hold(SESSION, 'desktop-chat:shutdown-race') - connections[0]?.handlers.onExit?.(new Error('recovery is still opening')) + connections[0]?.handlers.onExit?.(new Error('the first child exited')) + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ) + const body = hostTestMessage('start again') + await host.send( + { callerKey: 'runtime-test' }, + { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } + ) + // The send's start is still opening its connection when the quit begins. await vi.waitFor(() => expect(opens).toBe(2)) let stopped = false @@ -388,7 +409,6 @@ describe('structured session runtime provider-exit wiring', () => { expect(await host.attach({ callerKey: 'runtime-test' }, attachParams)).toMatchObject({ ok: true }) - await host.hold(SESSION, 'desktop-chat:backstop') await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow() await new Promise((resolve) => setImmediate(resolve)) @@ -397,7 +417,7 @@ describe('structured session runtime provider-exit wiring', () => { expect(closeAttempts).toBeGreaterThanOrEqual(2) // The callback it delivered neither reacquired nor wrote a technical row. expect(connections).toHaveLength(1) - const history = host.history({ sessionId: SESSION, direction: 'tail' }) + const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false) // The aborted eviction left the session reachable, so the next teardown is a real retry. diff --git a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts index 602507ef4d7..91bf91738b5 100644 --- a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts @@ -1,5 +1,5 @@ -// A Claude child proving its start must not wait on another session's exit recovery, and must -// not make that recovery, or the session's own serialized operations, wait on the CLI: the host +// A Claude child proving its start must not wait on another session's start, and must not make +// that start, or the session's own serialized operations, wait on the CLI: the host // records what the child proved from what the adapter already holds. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -18,7 +18,7 @@ afterEach(async () => { }) describe('a Claude child proving its start', () => { - it("never holds another session's exit recovery, or its own close, on a CLI read", async () => { + it("never holds another session's start, or its own close, on a CLI read", async () => { claude.behave(STALLED, { stallsControlReads: true }) const host = await claude.install() @@ -56,23 +56,15 @@ describe('a Claude child proving its start', () => { await vi.waitFor(() => expect(closed).toBe(true)) }) - it("flips to ready while another session's exit recovery is still acquiring", async () => { + it("flips to ready while another session's start is still acquiring", async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(HEALTHY, null))).resolves.toMatchObject({ - ok: true - }) - await host.hold(HEALTHY, 'surface-1') - await waitForStructuredAgentSessionRecovery() - - // Its exit recovery reacquires, and that spawn never returns. The exit hands the lease back - // and the restart queued behind it reserves it again at once, so `released` is not a state a - // poll can count on seeing; `reserved` with the spawn hanging is what "still acquiring" is. + // This start never returns from its spawn, so its lease stays reserved with no child. claude.behave(HEALTHY, { spawnHangs: true }) - claude.child(HEALTHY).exit(new Error('claude stream-json exited (code 1): crashed')) + void host.attach(CALLER, claude.attachParams(HEALTHY, null)).catch(() => undefined) await vi.waitFor(() => expect(host.deps.store.getRecord(HEALTHY)?.lease.claimStatus).toBe('reserved') ) - expect(claude.children(HEALTHY)).toHaveLength(1) + const childrenWhileHung = claude.children(HEALTHY).length await expect(host.attach(CALLER, claude.attachParams(STALLED, null))).resolves.toMatchObject({ ok: true @@ -83,9 +75,9 @@ describe('a Claude child proving its start', () => { effort: 'high' }) ) - // The other recovery is still where it was: reserved, with no child yet. + // The other start is still where it was: reserved, with no new child. expect(host.deps.store.getRecord(HEALTHY)?.lease.claimStatus).toBe('reserved') - expect(claude.children(HEALTHY)).toHaveLength(1) + expect(claude.children(HEALTHY)).toHaveLength(childrenWhileHung) }) it('is drained by the runtime before teardown proceeds', async () => { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index a51da3f1c3c..e6b458eb260 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -104,6 +104,8 @@ export type StructuredAgentSessionRuntimeDeps = { onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] /** The agent-status store; see `StructuredAgentSessionHostDeps.statusSink`. */ statusSink?: StructuredAgentSessionHostDeps['statusSink'] + /** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */ + hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch'] reapOrphanChildren?: typeof stopOrphanAgentSessionChildren /** The account home a structured launch would pin right now, for catalog * reads with no session record. Absent disables the catalog surface. */ @@ -312,6 +314,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise(): Promise => new Promise(() => {}) const optionWrite = (subtype: string): Promise => { child.calls.push(subtype) - return control(subtype, () => (behavior.optionWritesHang ? never() : Promise.resolve())) + return control(subtype, () => + behavior.optionWritesFail + ? Promise.reject(new Error('Query closed before response received')) + : behavior.optionWritesHang + ? never() + : Promise.resolve() + ) } let settingsReads = 0 const child: ScriptedClaudeChild = { @@ -110,7 +126,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { handlers.onMessage?.({ type: 'system', subtype: 'init', - session_id: providerSessionId, + session_id: behavior.initNamesForeignSession ? 'foreign-session' : providerSessionId, model: 'claude-sonnet-5', apiKeySource: 'none' }) @@ -156,7 +172,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { setPermissionMode: () => optionWrite('set_permission_mode'), applyFlagSettings: () => optionWrite('apply_flag_settings'), interrupt: async () => undefined, - cancelAsyncMessage: async () => {}, + cancelAsyncMessage: async () => false, stopTask: async () => {}, send: async () => { child.calls.push('send') @@ -175,7 +191,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { } const exitAtSpawn = (child: ScriptedClaudeChild, diagnostic: string): void => { child.connection.closed = true - child.exit(new Error(diagnostic)) + child.exit(scriptedClaudeExitError(diagnostic)) } /** A pid whose process is gone has no start time to read. */ const readProcessStartTime = async (pid: number): Promise => { diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index 602aaa3d3aa..f33d7889b31 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -62,8 +62,8 @@ function installHost(options: { /** * Sessions this host is not holding, so they observe `unverifiable` rather than `live`. * - * The everyday shape, not an edge case: the provider child belongs to the VISIBLE pane, so any - * chat outside the active workspace has already been evicted by the release clock. + * The everyday shape, not an edge case: the idle sweep has already put to rest any chat quiet for + * its window, on screen or not. */ detached?: Set /** diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index f79d5d12812..3f8ab31b3ec 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -27,7 +27,10 @@ import { } from '../../shared/execution-host' import { STILL_LIVE_DETAIL_PREFIX } from '../../shared/worktree/removal' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { observeStructuredWorker } from './structured-worker-authority' +import { + observeStructuredWorker, + structuredSessionCloseSettled +} from './structured-worker-authority' import { closeStructuredAgentSessionChild } from './structured-agent-session-close' import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement' import type { WorktreePtyHostFence } from './worktree-pty-host-fence' @@ -86,12 +89,9 @@ export function structuredSessionTeardownHostId( * The workspace's structured sessions, split into what belongs to it and what is attached. * * MEMBERSHIP and LIVENESS answer different questions, and folding them into one list is what let - * a chat tab outlive its workspace. A provider child is scoped to a VISIBLE pane — the hold that - * keeps one is `enabled: isVisible && isWorktreeActive`, and dropping the last hold evicts the - * child after the release grace — so `live` really means "this chat is the visible pane in the - * active workspace, or was moments ago". Deleting a workspace from the sidebar while a different - * one is active makes every chat in the target non-live. Those are exactly the sessions a - * liveness-only list never saw. + * a chat tab outlive its workspace. A provider child runs from a send until the idle sweep rests + * it, so `live` only means "this chat's agent worked recently", and every chat at rest in the + * target is non-live. Those are exactly the sessions a liveness-only list never saw. */ export type StructuredSessionsForWorktree = { /** Every session bound to this workspace on the fenced host, attached or not. */ @@ -256,11 +256,10 @@ export async function closeStructuredSessionsForWorktree( } = {} ): Promise { const { runtime, mayRefuse } = options - // No `afterClose` for a dispatched worker: `host.close` drops the holds, so nothing keeps a - // provider child un-evictable, but the dispatch's redrive subscription and registry entry do - // survive until it settles by another verb. That is a bounded leak, not a hazard — and passing - // one here would mean resolving a dispatch id per session on a teardown path that must stay - // inside the sweep deadline. + // No `afterClose` for a dispatched worker: `host.close` stops the child, but the dispatch's + // redrive subscription and registry entry survive until it settles by another verb. That is a + // bounded leak, not a hazard — and passing one here would mean resolving a dispatch id per + // session on a teardown path that must stay inside the sweep deadline. for (const session of progress.sessions) { // Stops ISSUING new closes once the budget is spent; an in-flight one is left to finish, since // nothing here can cancel a provider round trip. Without this, one slow round trip starved @@ -279,7 +278,7 @@ export async function closeStructuredSessionsForWorktree( // Re-observed rather than reusing the close's own reason string: what the user is asked to // waive is the state AFTER the attempt, and a close that threw never reached an observation. const status = observeStructuredWorker({ sessionId: session.sessionId }).status - if (status === 'exited') { + if (status === 'exited' || structuredSessionCloseSettled(session.sessionId)) { // The re-read can PROVE the exit a failed close could not — it threw past its own // observation, or the record's death evidence landed after it read. Refusing on a child // that is demonstrably gone is the defect this sweep exists to remove, so take the proof diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts new file mode 100644 index 00000000000..28317e4da88 --- /dev/null +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -0,0 +1,347 @@ +// A structured worker whose agent is at rest: its dispatch keeps it running while open, and once it +// rests it is still this runtime's worker — mail reaches it — until its chat tab is gone. Whether +// its process runs is a separate answer, and a close counts a released lease as done. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { OrchestrationDb } from './orchestration/db' + +const hostRef: { current: unknown } = { current: null } + +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const { observeStructuredWorker, resolveStructuredWorkerAuthority, structuredSessionCloseSettled } = + await import('./structured-worker-authority') +const { structuredWorkerOwesWork, structuredWorkerOwned } = + await import('./structured-worker-custody') +const { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} = await import('./structured-worker-identity') +const { listAddressableStructuredWorkers } = + await import('./orchestration/structured-worker-group-addressing') +const { closeStructuredAgentSessionChild } = await import('./structured-agent-session-close') +const { resolveGroupAddress } = await import('./orchestration/groups') +const { createRestTestRig, foundRestTestChat, IDLE_MS, REST_TEST_SESSION, sweepTicks } = + await import('../native-chat/agent-session-wire/structured-agent-session-rest-test-rig') + +const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const LOCAL = JSON.stringify({ kind: 'local', hostId: 'local' }) + +function record(lease: Partial): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial record; the ownership and liveness reads touch only lease, location and provider. + return { + sessionId: SESSION, + provider: 'codex', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'wt_1', + workspaceKind: 'git-worktree' + }, + lease: { claimStatus: 'live', deathEvidence: null, runtimeFence: 3, ...lease } + } as AgentSessionRecord +} + +function installHost(current: AgentSessionRecord | null, tabs: string[]) { + const host = { + deps: { store: { getRecord: () => current } }, + hasSession: () => false, + getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: tabs }), + setSessionTabVisibility: vi.fn(async (_id: string, visible: boolean) => { + tabs.splice(0, tabs.length, ...(visible ? [SESSION] : [])) + }), + close: vi.fn(async () => undefined) + } + hostRef.current = host + return host +} + +let paneKey = '' + +function registerWorker(): string { + const handle = mintStructuredWorkerHandle() + paneKey = mintStructuredWorkerPaneKey(SESSION) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION, + agent: 'codex', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + return handle +} + +beforeEach(() => { + structuredWorkerIdentities.clear() + hostRef.current = null +}) + +describe('an open dispatch keeps its worker running (P2-19 i)', () => { + let db: OrchestrationDb + beforeEach(() => { + db = new OrchestrationDb(':memory:') + }) + afterEach(() => db.close()) + + function dispatchWorker(hostScope = LOCAL) { + const task = db.createTask({ runId: 'run_legacy_local', spec: 'structured work' }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: mintStructuredWorkerHandle(), + paneKey: mintStructuredWorkerPaneKey(SESSION), + processIncarnation: structuredWorkerProcessIncarnation(SESSION), + worktreeId: 'wt_1', + effects: [], + setupState: 'not_configured', + hostScope, + terminalOwnership: 'created' + }) + return dispatch + } + + it('answers open while the dispatch starts, runs or stops, and closed once it settles', () => { + const dispatch = dispatchWorker() + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) + db.markWorkerDispatchReady(dispatch.id) + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) + db.beginWorkerStop(dispatch.id, 'epoch_home') + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) + db.settleWorkerStop(dispatch.id) + expect(structuredWorkerOwesWork(db, record({}))).toBe(false) + }) + + // Custody is the list state coordinators see: `active` owes work, `reclaimable` does not. + it('lets a worker awaiting its coordinator rest, and keeps one whose stop is in doubt', () => { + const done = dispatchWorker() + db.markWorkerDispatchReady(done.id) + db.settleWorkerReport({ + taskId: done.task_id, + dispatchId: done.id, + outcome: 'succeeded', + result: 'done' + }) + expect(structuredWorkerOwesWork(db, record({}))).toBe(false) + + const doubted = dispatchWorker() + db.markWorkerDispatchReady(doubted.id) + db.beginWorkerStop(doubted.id, 'epoch_home') + db.markWorkerStopUnknown(doubted.id, 'the close was not proven') + expect(structuredWorkerOwesWork(db, record({}))).toBe(true) + }) + + it('keeps a worker at rest after its dispatch settles a group recipient, until its tab goes (P2-19 ii)', () => { + const dispatch = dispatchWorker() + db.markWorkerDispatchReady(dispatch.id) + db.beginWorkerStop(dispatch.id, 'epoch_home') + db.settleWorkerStop(dispatch.id) + // Settlement forgets the in-memory entry; only the durable row and the record remain. + structuredWorkerIdentities.clear() + const tabs = [SESSION] + installHost( + record({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'stopped by the sweep', observedAt: 1 } + }), + tabs + ) + const handle = db.getWorkerTerminalResourceByOwner(dispatch.id)!.terminal_handle + const recipients = () => listAddressableStructuredWorkers(db) + + expect(recipients()).toEqual([{ handle, worktreeId: 'wt_1', agentIdentity: 'codex' }]) + expect(resolveGroupAddress('@codex', 'term_sender', recipients(), () => 'idle')).toEqual([ + handle + ]) + expect(resolveGroupAddress('@claude', 'term_sender', recipients(), () => 'idle')).toEqual([]) + // Direct mail resolves the same worker through the same ownership answer. + expect(resolveStructuredWorkerAuthority(handle, db)?.identity.handle).toBe(handle) + + tabs.length = 0 + expect(recipients()).toEqual([]) + expect(resolveStructuredWorkerAuthority(handle, db)).toBeNull() + }) + + it('stops routing to a worker its coordinator abandoned and then released, and keeps its tab', () => { + const dispatch = dispatchWorker() + db.markWorkerDispatchReady(dispatch.id) + structuredWorkerIdentities.clear() + const tabs = [SESSION] + installHost( + record({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'stopped by the sweep', observedAt: 1 } + }), + tabs + ) + const resource = db.getWorkerTerminalResourceByOwner(dispatch.id)! + const handle = resource.terminal_handle + const recipients = () => listAddressableStructuredWorkers(db) + // At rest, its dispatch abandoned: still a recipient, as a terminal worker left running is. + db.abandonWorkerDispatch(dispatch.id) + const worktreeGroup = () => + resolveGroupAddress('@worktree:wt_1', 'term_sender', recipients(), () => 'idle') + expect(worktreeGroup()).toEqual([handle]) + expect(resolveStructuredWorkerAuthority(handle, db)).not.toBeNull() + + // The release finds the agent at rest, so it settles as released. + expect(db.requestWorkerTerminalRelease(dispatch.id)).toMatchObject({ disposition: 'retained' }) + expect( + db.settleDeadWorkerTerminalRelease({ + requestingDispatchId: dispatch.id, + resourceId: resource.id, + processIncarnation: resource.process_incarnation! + }) + ).toMatchObject({ disposition: 'released' }) + + expect(tabs).toEqual([SESSION]) + expect(worktreeGroup()).toEqual([]) + // Direct mail routes through the same answer. + expect(resolveStructuredWorkerAuthority(handle, db)).toBeNull() + }) + + it('reads only this host scope, and no database answers no', () => { + dispatchWorker(JSON.stringify({ kind: 'ssh', targetId: 'elsewhere' })) + expect(structuredWorkerOwesWork(db, record({}))).toBe(false) + expect(structuredWorkerOwesWork(null, record({}))).toBe(false) + }) +}) + +describe('ownership, not liveness (P2-19 ii-iv, P2-26)', () => { + it('owns a worker at rest while its tab is listed, and routes mail to it', () => { + const handle = registerWorker() + installHost( + record({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'stopped', observedAt: 1 } + }), + [SESSION] + ) + + expect(structuredWorkerOwned(SESSION)).toBe(true) + expect(observeStructuredWorker({ sessionId: SESSION }).status).toBe('exited') + expect(resolveStructuredWorkerAuthority(handle, null)?.identity.paneKey).toBe(paneKey) + }) + + it('retires a released worker whose tab is gone', () => { + const handle = registerWorker() + installHost(record({ claimStatus: 'released' }), []) + + expect(structuredWorkerOwned(SESSION)).toBe(false) + expect(resolveStructuredWorkerAuthority(handle, null)).toBeNull() + }) + + it('keeps authority for a live session that has no tab', () => { + const handle = registerWorker() + installHost(record({ claimStatus: 'live' }), []) + + expect(structuredWorkerOwned(SESSION)).toBe(true) + expect(resolveStructuredWorkerAuthority(handle, null)).not.toBeNull() + }) + + it('cannot answer without a host', () => { + expect(structuredWorkerOwned(SESSION)).toBeNull() + }) +}) + +describe('a close whose stop could not be proven (P2-30)', () => { + it('closes on the first attempt, keeps the tab retired and leaves the verdict alone', async () => { + const released = record({ claimStatus: 'released', deathEvidence: null }) + const tabs = [SESSION] + const host = installHost(released, tabs) + expect(observeStructuredWorker({ sessionId: SESSION }).status).toBe('unverifiable') + + const outcome = await closeStructuredAgentSessionChild(SESSION) + expect(outcome).toMatchObject({ stopped: true, closeAttempted: true }) + expect(host.close).toHaveBeenCalledOnce() + expect(tabs).toEqual([]) + expect(host.setSessionTabVisibility).not.toHaveBeenCalledWith(SESSION, true) + expect(released.lease.deathEvidence).toBeNull() + expect(structuredSessionCloseSettled(SESSION)).toBe(true) + }) + + it('still refuses a close that left the lease live', () => { + installHost(record({ claimStatus: 'live' }), [SESSION]) + expect(structuredSessionCloseSettled(SESSION)).toBe(false) + }) +}) + +describe('a task dispatched into a worker whose own dispatch settled', () => { + it('keeps the worker running while that task is open, and lets it rest once the task settles', async () => { + const db = new OrchestrationDb(':memory:') + const rig = await createRestTestRig({ + hasOpenDispatch: (current) => structuredWorkerOwesWork(db, current) + }) + try { + hostRef.current = rig.host + await foundRestTestChat(rig) + const incarnation = structuredWorkerProcessIncarnation(REST_TEST_SESSION) + const handle = mintStructuredWorkerHandle() + const workerPane = mintStructuredWorkerPaneKey(REST_TEST_SESSION) + const first = db.createTask({ runId: 'run_legacy_local', spec: 'first task' }) + const { dispatch: started } = db.createStartingWorkerDispatch({ + taskId: first.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 9 + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.id, + handle, + paneKey: workerPane, + processIncarnation: incarnation, + worktreeId: 'wt_1', + effects: [], + setupState: 'not_configured', + hostScope: LOCAL, + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(started.id) + expect( + db.settleWorkerReport({ + taskId: first.id, + dispatchId: started.id, + outcome: 'succeeded', + result: 'done' + }) + ).toMatchObject({ action: 'settled' }) + // The coordinator hands the same worker its next task: a dispatch with no worker row. + const second = db.createTask({ runId: 'run_legacy_local', spec: 'second task' }) + const handedOn = db.createDispatchContext({ + taskId: second.id, + assigneeHandle: handle, + assigneePaneKey: workerPane, + processIncarnation: incarnation, + creator: { kind: 'system' }, + maxDepth: 9 + }) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks(12) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('live') + + db.completeDispatch(handedOn.id) + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => + expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('exited') + ) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION) + } finally { + hostRef.current = null + await rig.dispose() + db.close() + } + }) +}) diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index 96d013c2ed3..24fda6b1456 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -3,8 +3,10 @@ * * The registry holds the handle→session mapping for this process; the durable worker-terminal * resource row is what survives a restart, so a miss falls back to rehydrating from it. The - * durable agent-session record is the liveness half: a session whose claim is conflicted or - * released, or pinned to another execution host, is no longer this runtime's structured worker. + * durable agent-session record, the chat's tab and the orchestration's own resource row decide + * custody: see `structured-worker-custody`. + * Whether its provider process runs is a separate fact, `observeStructuredWorker`, and routing + * never reads it — an agent at rest still receives mail, which starts it. */ import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -12,11 +14,11 @@ import type { OrcaSessionId } from '../../shared/orca-session-address' import type { RuntimeTerminalState } from '../../shared/runtime-types' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrchestrationDb } from './orchestration/db' +import { structuredWorkerAddressable } from './structured-worker-custody' import { isStructuredWorkerHandle, structuredWorkerIdentities, structuredWorkerProcessIncarnation, - structuredWorkerRecordIsCurrent, type StructuredWorkerIdentity } from './structured-worker-identity' @@ -82,7 +84,8 @@ export function isRecordedStructuredWorkerSession( ) } -/** Identity plus a record that still proves this runtime owns the session. */ +/** Identity plus a record that still proves this runtime owns the session, for a worker its + * orchestration has not released. */ export function resolveStructuredWorkerAuthority( handle: string, db: OrchestrationDb | null | undefined @@ -92,7 +95,14 @@ export function resolveStructuredWorkerAuthority( return null } const record = readStructuredAgentSessionRecord(identity.sessionId) - return record && structuredWorkerRecordIsCurrent(record) ? { identity, record } : null + return record && + structuredWorkerAddressable( + db, + identity.sessionId, + db?.getWorkerTerminalResourceByHandle?.(identity.handle) + ) + ? { identity, record } + : null } /** @@ -115,6 +125,20 @@ export type StructuredWorkerObservation = { reason?: string } +/** + * Whether a close left nothing running: `exited`, or `unverifiable` on a released lease — a release + * whose stop could not be proven, which sent no signal and is left as it is. Closing a chat is the + * user's action, and bookkeeping about a process already released must not refuse it. + */ +export function structuredSessionCloseSettled(sessionId: string): boolean { + const status = observeStructuredWorker({ sessionId }).status + return ( + status === 'exited' || + (status === 'unverifiable' && + readStructuredAgentSessionRecord(sessionId)?.lease.claimStatus === 'released') + ) +} + /** * The observation as the terminal state every read result reports. * diff --git a/src/main/runtime/structured-worker-custody.ts b/src/main/runtime/structured-worker-custody.ts new file mode 100644 index 00000000000..54e42b24d80 --- /dev/null +++ b/src/main/runtime/structured-worker-custody.ts @@ -0,0 +1,133 @@ +/** + * Whether orchestration still holds a structured worker, the one answer every reader derives from. + * + * Custody is the orchestration's own worker-terminal state, the list state `worker-list` shows + * coordinators, never whether the worker's process runs: a worker at rest is still held, and mail + * starts it. Routing, group addressing and `worker-show` ask whether it is addressable; the idle + * sweep asks whether it still owes work. + * + * Two policy decisions live here and nowhere else: only `released` ends addressability (a release + * pending or in doubt still routes, as for a terminal worker), and a settled worker awaiting its + * coordinator's decision (`reclaimable`) owes no work, so it may rest. + */ + +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { OrchestrationDb } from './orchestration/db' +import type { WorkerDispatchState } from './orchestration/types' +import { + deriveWorkerTerminalListState, + type WorkerTerminalResourceRow +} from './orchestration/worker-terminal-ownership' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + structuredWorkerHostScope, + structuredWorkerProcessIncarnation, + structuredWorkerRecordIsCurrent +} from './structured-worker-identity' + +/** + * Whether this runtime still owns the worker's session: routing, addressing and authority ask + * this, never whether its process runs. Null when the host is not installed, because reading the + * record store would install it — not being able to look is not an answer. + */ +export function structuredWorkerOwned(sessionId: string): boolean | null { + const host = getStructuredAgentSessionHost() + if (!host) { + return null + } + let record: AgentSessionRecord | null + try { + record = host.deps.store.getRecord(sessionId) + } catch { + record = null + } + return structuredWorkerRecordIsCurrent( + record, + record?.lease.claimStatus === 'released' && structuredWorkerTabListed(host, sessionId) + ) +} + +/** Retirement is the tab index: every path that ends a chat for good hides its tab. */ +function structuredWorkerTabListed( + host: NonNullable>, + sessionId: string +): boolean { + try { + return host.getPersistedVisibleSessionTabIndex?.().sessionIds.includes(sessionId) ?? false + } catch { + return false + } +} + +type CustodyRow = Pick< + WorkerTerminalResourceRow, + 'owner_dispatch_id' | 'terminal_handle' | 'ownership_state' | 'release_state' +> + +function ownerState( + db: OrchestrationDb | null | undefined, + row: CustodyRow +): WorkerDispatchState | undefined { + return ( + db?.getWorkerDispatch?.(row.owner_dispatch_id)?.state ?? + db?.getRemoteDispatchAttachment?.(row.owner_dispatch_id)?.state + ) +} + +/** + * The user still owns the chat and orchestration has not released the worker, as with a terminal + * worker whose terminal closed. Null when ownership cannot be read. A released worker's chat stays + * the user's; nothing routes to it. + */ +export function structuredWorkerAddressable( + db: OrchestrationDb | null | undefined, + sessionId: string, + row: CustodyRow | undefined +): boolean | null { + const owned = structuredWorkerOwned(sessionId) + if (owned === null) { + return null + } + // Release is read off the row alone, so an owner whose state is unreadable still answers. + const custody = row + ? deriveWorkerTerminalListState({ + workerState: ownerState(db, row) ?? 'unsupervised', + agentTerminalHandle: row.terminal_handle, + resource: row + }) + : null + return owned && custody !== 'released' +} + +/** + * Work orchestration still owes on this worker, read per sweep tick: any unsettled dispatch + * addressed to its incarnation, on a process this host owns a terminal for. That covers its own + * worker-start dispatch (whose context stays open while the worker is active, a stop in doubt + * included, because a supervised worker's context settles only with it) and any task later + * dispatched to it. A `reclaimable` worker's dispatch has settled, so it owes nothing. + */ +export function structuredWorkerOwesWork( + db: OrchestrationDb | null, + record: AgentSessionRecord +): boolean { + const hostScope = structuredWorkerHostScope(record.location) + if (!db || !hostScope) { + return false + } + const incarnation = structuredWorkerProcessIncarnation(record.sessionId) + const owned = db.db + .prepare( + `SELECT 1 FROM worker_terminal_resources + WHERE process_incarnation = ? AND host_scope IS ? AND ownership_state = 'owned' LIMIT 1` + ) + .get(incarnation, JSON.stringify(hostScope)) + return ( + owned !== undefined && + db.db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE process_incarnation = ? AND status IN ('pending', 'dispatched') LIMIT 1` + ) + .get(incarnation) !== undefined + ) +} diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index 98989a85891..d20cadf5c77 100644 --- a/src/main/runtime/structured-worker-identity.test.ts +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -176,18 +176,24 @@ describe('structured worker identity', () => { it('keeps a recovered session current across a fence bump', () => { // The host bumps the fence on its own transparent crash recovery; fencing identity on it // would wedge the SAME worker as identity_unproven forever. - expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 1 }))).toBe(true) - expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 9 }))).toBe(true) + expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 1 }), false)).toBe(true) + expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 9 }), false)).toBe(true) expect(structuredWorkerProcessIncarnation(SESSION_ID)).toBe( structuredWorkerProcessIncarnation(SESSION_ID) ) }) - it('refuses a conflicted or released session', () => { + it('refuses a conflicted session, and a released one whose chat tab is gone', () => { // A terminal owner an older build recorded loads conflicted; it is not this worker. - expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'conflicted' }))).toBe(false) - expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }))).toBe(false) - expect(structuredWorkerRecordIsCurrent(null)).toBe(false) + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'conflicted' }), true)).toBe(false) + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }), false)).toBe(false) + expect(structuredWorkerRecordIsCurrent(null, true)).toBe(false) + }) + + it('keeps a released session with a listed tab: that worker is at rest, not gone', () => { + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }), true)).toBe(true) + // A live lease is owned whether or not a tab shows it. + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'live' }), false)).toBe(true) }) }) diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index c32d6c37f15..e09adee7c1e 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -141,19 +141,62 @@ export function structuredWorkerHostScope( : null } -/** Whether the durable record still describes THIS worker under this host. */ +/** + * Whether the durable record still describes THIS worker under this host — ownership, not whether + * its process runs. A released lease is a worker at rest while its chat tab is listed; released + * with the tab gone is retired. `tabListed` is the persisted tab index's answer. + */ export function structuredWorkerRecordIsCurrent( - record: AgentSessionRecord | null | undefined + record: AgentSessionRecord | null | undefined, + tabListed: boolean ): boolean { return Boolean( record && // Why: a conflicted claim may name a terminal an older build recorded as owner, not this worker. record.lease.claimStatus !== 'conflicted' && - record.lease.claimStatus !== 'released' && + (record.lease.claimStatus !== 'released' || tabListed) && structuredWorkerHostScope(record.location) ) } +type StructuredWorkerResourceRow = { + terminal_handle: string + pane_key: string | null + process_incarnation: string | null + worktree_id: string | null + host_scope: string | null +} + +/** A worker's identity as its durable worker-terminal resource row records it, or null for a row + * that is not a structured worker's or whose pane key does not belong to its own session. */ +export function structuredWorkerIdentityFromRow( + row: StructuredWorkerResourceRow +): StructuredWorkerIdentity | null { + const sessionId = sessionIdFromStructuredWorkerIncarnation(row.process_incarnation) + const hostScope = parseWorkerTerminalHostScope(row.host_scope) + const paneKey = row.pane_key + if ( + !sessionId || + !hostScope || + !row.worktree_id || + !paneKey || + !isStructuredWorkerHandle(row.terminal_handle) || + !persistedStructuredWorkerPaneKeyIsValid(paneKey, sessionId) + ) { + return null + } + return { + handle: row.terminal_handle, + sessionId, + // The row does not carry the provider; callers that need it read the durable record. + agent: null, + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(sessionId), + worktreeId: row.worktree_id, + hostScope + } +} + export class StructuredWorkerIdentityRegistry { private readonly byHandle = new Map() private readonly bySessionId = new Map() @@ -193,35 +236,9 @@ export class StructuredWorkerIdentityRegistry { * only place a structured worker's pane key and host scope outlive this process. A row whose * pane key does not belong to its own recorded session is refused rather than trusted. */ - rehydrate(row: { - terminal_handle: string - pane_key: string | null - process_incarnation: string | null - worktree_id: string | null - host_scope: string | null - }): StructuredWorkerIdentity | null { - const sessionId = sessionIdFromStructuredWorkerIncarnation(row.process_incarnation) - const hostScope = parseWorkerTerminalHostScope(row.host_scope) - if ( - !sessionId || - !hostScope || - !row.worktree_id || - !isStructuredWorkerHandle(row.terminal_handle) || - // The durable row bootstraps the registry after restart, so validate it before registration. - !persistedStructuredWorkerPaneKeyIsValid(row.pane_key, sessionId) - ) { - return null - } - return this.register({ - handle: row.terminal_handle, - sessionId, - // The row does not carry the provider; callers that need it read the durable record. - agent: null, - paneKey: row.pane_key, - processIncarnation: structuredWorkerProcessIncarnation(sessionId), - worktreeId: row.worktree_id, - hostScope - }) + rehydrate(row: StructuredWorkerResourceRow): StructuredWorkerIdentity | null { + const identity = structuredWorkerIdentityFromRow(row) + return identity ? this.register(identity) : null } clear(): void { diff --git a/src/main/runtime/structured-worker-mail-routing.test.ts b/src/main/runtime/structured-worker-mail-routing.test.ts index c426132b848..0413c7b6fa6 100644 --- a/src/main/runtime/structured-worker-mail-routing.test.ts +++ b/src/main/runtime/structured-worker-mail-routing.test.ts @@ -20,9 +20,16 @@ const prototype = OrcaRuntimeWithAdoptTerminalOrphansFromInventory.prototype const getLivePaneKey = prototype.getLiveTerminalPaneKey const resolveActiveTerminal = prototype.resolveActiveTerminal -function installRecord(lease: { runtimeKind: string; claimStatus: string } | null): void { +function installRecord( + lease: { runtimeKind: string; claimStatus: string } | null, + tabListed = false +): void { hostRef.current = lease ? { + getPersistedVisibleSessionTabIndex: () => ({ + present: true, + sessionIds: tabListed ? [SESSION_ID] : [] + }), deps: { store: { getRecord: () => ({ @@ -73,10 +80,19 @@ describe('bare-handle direct mail to a structured session', () => { ) }) - it('withholds the pane key when the session is not proven live', () => { - // The PTY branch is connected-gated so mail is never routed to a corpse; so is this one. + it('routes to a worker at rest, whose agent the mail starts', () => { + // Released by the idle sweep with its chat tab still listed: owned, so mail reaches it. const handle = registerWorker() - installRecord({ runtimeKind: 'native', claimStatus: 'reserved' }) + installRecord({ runtimeKind: 'native', claimStatus: 'released' }, true) + expect(getLivePaneKey.call(paneKeyStub, handle)).toBe( + structuredWorkerIdentities.get(handle)!.paneKey + ) + }) + + it('withholds the pane key from a retired worker: released, with its tab gone', () => { + // Mail is never routed to a worker this runtime no longer owns. + const handle = registerWorker() + installRecord({ runtimeKind: 'native', claimStatus: 'released' }, false) expect(getLivePaneKey.call(paneKeyStub, handle)).toBeNull() }) diff --git a/src/main/runtime/structured-worker-terminal-read.test.ts b/src/main/runtime/structured-worker-terminal-read.test.ts index 802b23733bc..e49f24ba1a1 100644 --- a/src/main/runtime/structured-worker-terminal-read.test.ts +++ b/src/main/runtime/structured-worker-terminal-read.test.ts @@ -75,22 +75,22 @@ describe('reading a structured worker through the terminal-read path', () => { hostRef.current = null }) - it('serves the journal as terminal lines, with no dispatch and no capability', () => { + it('serves the journal as terminal lines, with no dispatch and no capability', async () => { // The defect this pins: a peer has no dispatch id and no coordinator standing, so `worker-read` // is closed to it, and `terminal read` threw `terminal_handle_stale` for a perfectly live // worker. A peer could not see a structured agent's recent output at all. const handle = registerWorker() installHost({ items: [message('i1', 'first line\nsecond line'), message('i2', 'done')] }) - const read = readStructuredWorkerTerminal({ handle, db: null }) + const read = await readStructuredWorkerTerminal({ handle, db: null }) expect(read?.tail).toEqual(['[assistant] first line', 'second line', '[assistant] done']) expect(read?.status).toBe('running') expect(read?.truncated).toBe(false) }) - it('honours limit, and claims no cursor space it cannot honour', () => { + it('honours limit, and claims no cursor space it cannot honour', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'a'), message('i2', 'b'), message('i3', 'c')] }) - const read = readStructuredWorkerTerminal({ handle, db: null, limit: 2 }) + const read = await readStructuredWorkerTerminal({ handle, db: null, limit: 2 }) expect(read?.tail).toEqual(['[assistant] b', '[assistant] c']) // No index is advertised: the next read re-projects a sliding window, so 0/length would name // positions that address different lines by then. @@ -99,7 +99,7 @@ describe('reading a structured worker through the terminal-read path', () => { expect(read?.latestCursor).toBeUndefined() }) - it('refuses a cursor read rather than silently misdelivering lines', () => { + it('refuses a cursor read rather than silently misdelivering lines', async () => { // The PTY cursor indexes an append-only completed-line buffer with a monotone count. This // window is a bounded tail re-projected every read, so a saved index addresses different lines // as the journal grows — and `truncated` could never fire to say so, because it tests @@ -107,14 +107,10 @@ describe('reading a structured worker through the terminal-read path', () => { // duplicated lines with `truncated:false`. const handle = registerWorker() installHost({ items: [message('i1', 'a')] }) - const refusal = (() => { - try { - readStructuredWorkerTerminal({ handle, db: null, cursor: 0 }) - return '' - } catch (error) { - return (error as Error).message - } - })() + const refusal = await readStructuredWorkerTerminal({ handle, db: null, cursor: 0 }).then( + () => '', + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ) expect(refusal).toMatch(/not line-addressable/) // Tells the caller what DOES work here. Polling a bounded newest-last tail and diffing fails // safe — a harmless re-read — where a broken cursor fails unsafe, as a silent hole. @@ -125,35 +121,35 @@ describe('reading a structured worker through the terminal-read path', () => { expect(refusal).not.toContain('worker-read') }) - it('reports dropped history as truncated rather than pretending the page is whole', () => { + it('reports dropped history as truncated rather than pretending the page is whole', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'tail only')], hasOlder: true }) - expect(readStructuredWorkerTerminal({ handle, db: null })?.truncated).toBe(true) + expect((await readStructuredWorkerTerminal({ handle, db: null }))?.truncated).toBe(true) }) - it('redacts dispatch capability tokens the same way the archive path does', () => { + it('redacts dispatch capability tokens the same way the archive path does', async () => { const handle = registerWorker() const token = `dcap_${'a'.repeat(32)}` installHost({ items: [message('i1', `token is ${token} here`)] }) - const tail = readStructuredWorkerTerminal({ handle, db: null })?.tail.join('\n') ?? '' + const tail = (await readStructuredWorkerTerminal({ handle, db: null }))?.tail.join('\n') ?? '' expect(tail).not.toContain(token) expect(tail).toContain('[dispatch capability redacted]') }) - it('refuses when the session is not attached rather than answering an empty tail', () => { + it('refuses when the session is not attached rather than answering an empty tail', async () => { // An empty tail is the claim "this worker has produced no output", which is a different and // false statement — and the one a caller cannot tell apart from a real silence. const handle = registerWorker() installHost({ items: 'unreadable' }) - expect(() => readStructuredWorkerTerminal({ handle, db: null })).toThrow( + await expect(readStructuredWorkerTerminal({ handle, db: null })).rejects.toThrow( 'agent_session_ownership_unknown' ) }) - it('reports a session it cannot verify as unknown, never as running', () => { + it('reports a session it cannot verify as unknown, never as running', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'said something')], hasSession: false }) - expect(readStructuredWorkerTerminal({ handle, db: null })?.status).toBe('unknown') + expect((await readStructuredWorkerTerminal({ handle, db: null }))?.status).toBe('unknown') }) it('is what `terminal read` answers with, ahead of the PTY lookup', async () => { @@ -182,15 +178,15 @@ describe('reading a structured worker through the terminal-read path', () => { }) }) - it('leaves every handle that is not a live structured worker to the PTY path', () => { + it('leaves every handle that is not a live structured worker to the PTY path', async () => { const handle = registerWorker() installHost({ items: [message('i1', 'x')] }) - expect(readStructuredWorkerTerminal({ handle: 'term_abc', db: null })).toBeNull() + expect(await readStructuredWorkerTerminal({ handle: 'term_abc', db: null })).toBeNull() // A terminal owner an older build recorded loads conflicted: not this runtime's worker. installHost({ items: [message('i1', 'x')], lease: { runtimeKind: 'native', claimStatus: 'conflicted' } }) - expect(readStructuredWorkerTerminal({ handle, db: null })).toBeNull() + expect(await readStructuredWorkerTerminal({ handle, db: null })).toBeNull() }) }) diff --git a/src/main/runtime/structured-worker-terminal-read.ts b/src/main/runtime/structured-worker-terminal-read.ts index 9b4a118b8ae..3d31c4c863c 100644 --- a/src/main/runtime/structured-worker-terminal-read.ts +++ b/src/main/runtime/structured-worker-terminal-read.ts @@ -48,12 +48,12 @@ import { readTerminalTail } from './terminal-tail-read' * IS a structured worker never falls through: an unreadable journal refuses rather than answering * an empty tail, which a caller cannot tell from a worker that has said nothing. */ -export function readStructuredWorkerTerminal(args: { +export async function readStructuredWorkerTerminal(args: { handle: string db: OrchestrationDb | null cursor?: number limit?: number -}): RuntimeTerminalRead | null { +}): Promise { const identity = resolveStructuredWorkerAuthority(args.handle, args.db)?.identity if (!identity) { return null @@ -76,7 +76,7 @@ export function readStructuredWorkerTerminal(args: { 'A structured session has no durable line anchor to page from — nothing else does either.' ) } - const page = readStructuredJournalPage(identity.sessionId) + const page = await readStructuredJournalPage(identity.sessionId) if (!page) { // Honest refusal, and the same one the send lane reports: an empty tail would read as "this // worker has produced no output", which is a different and false claim. diff --git a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts index 628139923b0..81287bafc59 100644 --- a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts +++ b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts @@ -37,8 +37,7 @@ export async function runObservedExitSocketScenario(scenario: ObservedExitSocket rememberSyntheticKillExit: harness.session.rememberSyntheticKillExit, sendPtyExitToRenderer: harness.session.sendPtyExitToRenderer, finishPtyShutdown, - retiredRejectedPtyIds: new Map(), - reversibleStopOwnersByPtyId: new Map() + retiredRejectedPtyIds: new Map() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stop/kill read only these controller ports and optional store; spawn ports are unused. const deps = ports as unknown as PtyRuntimeControllerDeps diff --git a/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts new file mode 100644 index 00000000000..e806e88320c --- /dev/null +++ b/src/main/runtime/terminal-input-kind-unchecked-call-sites.test.ts @@ -0,0 +1,80 @@ +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { scanSourceTree } from '../../shared/source-scan/source-tree-scan' +import { + findCallsMissingArgument, + type RequiredCallArgument +} from '../../shared/source-scan/call-argument-scan' + +/** + * Every PTY write names its input kind, and the compiler enforces that everywhere except the + * runtime files split out with `@ts-nocheck`. There a missing kind would compile and silently + * record nothing, so this scan is the ratchet for them. + */ +const MAIN_ROOT = resolve(__dirname, '..') + +const namesInputKind = (literal: string): boolean => /\binputKind\b|\.\.\./.test(literal) +const kindAt = (index: number, receiver?: RegExp): RequiredCallArgument => ({ + index, + ...(receiver ? { receiver } : {}), + acceptsObjectLiteral: namesInputKind +}) +const CONTROLLER = /[Cc]ontroller\??\s*$/ + +const KIND_ARGUMENT_BY_METHOD: Record = { + write: kindAt(2, CONTROLLER), + writeWithSettlement: kindAt(2, CONTROLLER), + sendTerminal: kindAt(2), + sendTerminalAgentPrompt: kindAt(2), + writeTerminalAction: kindAt(3), + writeTerminalInputChunks: kindAt(2), + writeTerminalAgentPrompt: kindAt(4), + writeAction: kindAt(3), + writeChunks: kindAt(2) +} + +// Why multiline: some unchecked files open with a lint directive before `@ts-nocheck`. +const uncheckedSources = scanSourceTree(MAIN_ROOT).filter((file) => + /^\/\/ @ts-nocheck\b/m.test(file.source) +) + +describe('PTY write call sites the compiler cannot check', () => { + it('scans the unchecked runtime files that write to a PTY', () => { + expect(uncheckedSources.map((file) => file.relativePath)).toEqual( + expect.arrayContaining([ + 'runtime/orca-runtime-deliver-pending-messages.ts', + 'runtime/orca-runtime-create-pty-headless-terminal-state.ts', + 'runtime/orca-runtime-write-terminal-agent-prompt.ts', + 'runtime/orca-runtime-sync-window-graph.ts' + ]) + ) + }) + + it('finds a write, prompt or send that leaves out its kind', () => { + const planted = [ + '', + 'this.ptyController?.write(ptyId, reply)', + "this.ptyController.write(ptyId, '\\r', 'launch')", + "await this.sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", + 'await this.sendTerminalAgentPrompt(handle, prompt, { ...options })', + 'other.write(ptyId, data)', + 'const controller = this.ptyController; controller.write(ptyId, data)' + ].join('\n') + + expect(findCallsMissingArgument(planted, KIND_ARGUMENT_BY_METHOD)).toEqual([ + '2: .write(ptyId, reply)', + "4: .sendTerminal(handle, { text: 'a, b' }, { beforeWrite })", + '7: .write(ptyId, data)' + ]) + }) + + it('passes an input kind at every write', () => { + const missing = uncheckedSources.flatMap((file) => + findCallsMissingArgument(file.source, KIND_ARGUMENT_BY_METHOD).map( + (site) => `${file.relativePath}:${site}` + ) + ) + + expect(missing).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stop-exit.test.ts b/src/main/runtime/terminal-intentional-stop-exit.test.ts new file mode 100644 index 00000000000..0c3f996153f --- /dev/null +++ b/src/main/runtime/terminal-intentional-stop-exit.test.ts @@ -0,0 +1,288 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BrowserWindow } from 'electron' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { Store } from '../persistence/loading-store/store' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { wirePtyIpcSession } from '../ipc/pty/delivery/wire-session' +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' +import { bindProviderListeners } from '../ipc/pty/provider/bind-listeners' +import { + stopRendererOwnedPty, + stopReplacedPanePty, + type PtyKillIpcDeps +} from '../ipc/pty/ipc/renderer-kill' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' +import { createPtyIpcSession } from '../ipc/pty/session' +import type { IPtyProvider } from '../providers/types' +import { OrcaRuntimeService } from './orca-runtime' +import { + INCARNATION_ID, + LEAF_ID, + PTY_ID, + REPO_ID, + TAB_ID, + WORKTREE_ID, + WORKTREE_PATH, + makeSession +} from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture' +import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' + +const REPLACEMENT_PTY_ID = 'pty-close-continuity-replacement' +const REPLACEMENT_INCARNATION_ID = '77777777-7777-4777-8777-777777777777' +const LATER_INCARNATION_ID = '88888888-8888-4888-8888-888888888888' + +const directories: string[] = [] +const stores: Store[] = [] +const priorProvider = getLocalPtyProvider() +afterEach(() => { + vi.useRealTimers() + setLocalPtyProvider(priorProvider) + ptyOwnership.delete(PTY_ID) + ptyIncarnationById.delete(PTY_ID) + for (const store of stores.splice(0)) { + store.freezeWrites() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +/** A real store and runtime with one bound pane, and main's exit delivery to a renderer stub. + * `lateProviderExit`: the kill's reply overtakes the exit, so main synthesizes one and the + * provider's own exit arrives later through its listener. */ +function createHarness(opts: { lateProviderExit?: boolean; folder?: boolean } = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-intentional-stop-')) + directories.push(directory) + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority( + join(directory, 'profile-state.db'), + 'intentional-stop' + ) + }) + stores.push(store) + store.addRepo({ + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'Fixture', + badgeColor: 'gray', + addedAt: 1, + // Why: a folder workspace resolves without git, which the sleep transaction needs. + ...(opts.folder ? { kind: 'folder' as const } : {}) + }) + store.setWorkspaceSession(advanceTerminalTopologyRevision(makeSession(), WORKTREE_ID)) + store.flushOrThrow() + const runtime = new OrcaRuntimeService(store) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION_ID + }) + ptyOwnership.set(PTY_ID, null) + ptyIncarnationById.set(PTY_ID, INCARNATION_ID) + let emitProviderExit: + | ((payload: { id: string; code: number; incarnationId?: string }) => void) + | undefined + const provider = { + onData: () => () => {}, + onExit: (listener: typeof emitProviderExit) => { + emitProviderExit = listener + return () => {} + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listeners bind only onData and onExit, and the renderer kill hands the provider to the shutdown port below. + setLocalPtyProvider(provider as unknown as IPtyProvider) + const rendererSend = vi.fn() + const window = { isDestroyed: () => false, webContents: { send: rendererSend } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: exit delivery reads only isDestroyed and webContents.send. + const session = createPtyIpcSession({ mainWindow: window as unknown as BrowserWindow, runtime }) + wirePtyIpcSession(session) + bindProviderListeners(session) + const deps: PtyKillIpcDeps = { + store, + runtime, + getLocalPtyProviderStartupPromise: () => undefined, + // The provider's own exit, delivered the way its listener delivers it. + shutdownProviderAndDetectExit: async (_provider, id) => { + if (opts.lateProviderExit) { + return false + } + runtime.onPtyExit(id, 0, INCARNATION_ID, { providerExitObserved: true }) + session.sendPtyExitToRenderer({ id, code: 0, incarnationId: INCARNATION_ID }) + return true + }, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + sendPtyExitToRenderer: session.sendPtyExitToRenderer + } + return { + store, + runtime, + deps, + emitProviderExit: (incarnationId: string) => + emitProviderExit?.({ id: PTY_ID, code: 0, incarnationId }), + boundPtyId: () => + store.getWorkspaceSession().terminalLayoutsByTabId[TAB_ID]?.ptyIdsByLeafId?.[LEAF_ID] ?? null, + tabIds: () => (store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID] ?? []).map((t) => t.id), + rendererExits: () => + rendererSend.mock.calls.filter(([channel]) => channel === 'pty:exit').map(([, p]) => p) + } +} + +describe('intentional stops keep the pane through the exit', () => { + it('retires the pane when an ordinary close ends the process', async () => { + const harness = createHarness() + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID }) + + expect(harness.boundPtyId()).toBeNull() + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID } + ]) + }) + + it('keeps the tab and its wake binding when the renderer hibernates the pane', async () => { + const harness = createHarness() + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, preserveRendererBinding: true } + ]) + }) + + it('keeps a typed pane that a restart replaces, and binds the replacement', async () => { + const harness = createHarness() + harness.runtime.terminalRunFacts.recordSpawnCommit({ + id: PTY_ID, + incarnationId: INCARNATION_ID + }) + harness.runtime.terminalRunFacts.recordInput(PTY_ID, 'driving', 'ls\r') + + await stopReplacedPanePty(harness.deps, PTY_ID) + expect(harness.boundPtyId()).toBe(PTY_ID) + await harness.store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + ptyId: REPLACEMENT_PTY_ID, + incarnationId: REPLACEMENT_INCARNATION_ID, + origin: 'spawn' + }) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(REPLACEMENT_PTY_ID) + expect( + harness.store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[ + makePaneKey(TAB_ID, LEAF_ID) + ] + ).toBe(REPLACEMENT_INCARNATION_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: 0, incarnationId: INCARNATION_ID, replacedByRestart: true } + ]) + }) + + it('labels the exit for both a sleep and a restart that stop the same process', async () => { + const harness = createHarness() + const settleSleep = harness.runtime.intentionalPtyStops.mark( + PTY_ID, + 'reversible', + INCARNATION_ID + ) + + await stopReplacedPanePty(harness.deps, PTY_ID) + settleSleep(true) + + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { + id: PTY_ID, + code: 0, + incarnationId: INCARNATION_ID, + preserveRendererBinding: true, + replacedByRestart: true + } + ]) + }) + + it('keeps the pane through the synthetic exit and the provider exit that follows it', async () => { + const harness = createHarness({ lateProviderExit: true }) + + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + harness.emitProviderExit(INCARNATION_ID) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + expect(harness.rendererExits()).toEqual([ + { id: PTY_ID, code: -1, incarnationId: INCARNATION_ID, preserveRendererBinding: true } + ]) + }) + + it('never reads the exit of a later process on the same id as the stop', async () => { + const harness = createHarness({ lateProviderExit: true }) + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: LATER_INCARNATION_ID + }) + ptyIncarnationById.set(PTY_ID, LATER_INCARNATION_ID) + await harness.store.persistPtyBinding({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID, + incarnationId: LATER_INCARNATION_ID, + origin: 'reattach' + }) + + harness.emitProviderExit(LATER_INCARNATION_ID) + + // Why wait: an unstopped exit retires the pane through an async durable save. + await vi.waitFor(() => expect(harness.boundPtyId()).toBeNull()) + expect(harness.rendererExits().at(-1)).toEqual({ + id: PTY_ID, + code: 0, + incarnationId: LATER_INCARNATION_ID + }) + }) + + it('forgets the stop once the duplicate-exit window after it closes', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness({ lateProviderExit: true }) + await stopRendererOwnedPty(harness.deps, { id: PTY_ID, keepHistory: true }) + + vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1) + expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([ + 'reversible' + ]) + vi.advanceTimersByTime(1) + + expect(harness.runtime.intentionalPtyStops.claimExit(PTY_ID, INCARNATION_ID)).toEqual([]) + }) + + it('keeps the tab and its wake binding when the runtime puts the worktree to sleep', async () => { + const harness = createHarness({ folder: true }) + const inventories = [[{ id: PTY_ID, worktreeId: WORKTREE_ID, cwd: WORKTREE_PATH, title: 'a' }]] + harness.runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait: async (ptyId) => { + harness.runtime.onPtyExit(ptyId, -1, INCARNATION_ID, { providerExitObserved: true }) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => inventories.shift() ?? [] + }) + + await harness.runtime.sleepTerminalsForWorktree(`id:${WORKTREE_ID}`) + + expect(harness.tabIds()).toEqual([TAB_ID]) + expect(harness.boundPtyId()).toBe(PTY_ID) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stops.test.ts b/src/main/runtime/terminal-intentional-stops.test.ts new file mode 100644 index 00000000000..d37fc4147e8 --- /dev/null +++ b/src/main/runtime/terminal-intentional-stops.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' +import { TerminalIntentionalStops } from './terminal-intentional-stops' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('terminal intentional stops', () => { + it('keeps the mark while a second overlapping owner still holds it', () => { + const stops = new TerminalIntentionalStops() + const settleFirst = stops.mark('pty-1', 'reversible', 'inc-1') + const settleSecond = stops.mark('pty-1', 'reversible', 'inc-1') + + settleFirst(false) + + expect(stops.isReversibleStopInFlight('pty-1')).toBe(true) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + settleSecond(false) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + }) + + it('still reads an exit that lands after the stop settled, until the window closes', () => { + vi.useFakeTimers() + const stops = new TerminalIntentionalStops() + const settle = stops.mark('pty-ssh', 'reversible', 'inc-1') + + settle(true) + vi.advanceTimersByTime(SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS - 1) + + expect(stops.isReversibleStopInFlight('pty-ssh')).toBe(false) + expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual(['reversible']) + vi.advanceTimersByTime(1) + expect(stops.claimExit('pty-ssh', 'inc-1')).toEqual([]) + }) + + it('drops the mark at once when the stop fails', () => { + const stops = new TerminalIntentionalStops() + + stops.mark('pty-1', 'replaced', 'inc-1')(false) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + }) + + it('reads the synthetic exit and the provider exit of the same process alike', () => { + const stops = new TerminalIntentionalStops() + const settle = stops.mark('pty-1', 'replaced', null) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced']) + settle(true) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['replaced']) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('never marks the exit of another process that reuses the id', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1') + + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('starts a new stop of the same id fresh once the prior one settled', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1')(true) + + const settle = stops.mark('pty-1', 'replaced', 'inc-2') + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual([]) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual(['replaced']) + settle(false) + expect(stops.claimExit('pty-1', 'inc-2')).toEqual([]) + }) + + it('labels one exit with every kind of stop that overlapped on it', () => { + const stops = new TerminalIntentionalStops() + const settleSleep = stops.mark('pty-1', 'reversible', 'inc-1') + const settleRestart = stops.mark('pty-1', 'replaced', 'inc-1') + + expect(stops.isReversibleStopInFlight('pty-1')).toBe(true) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible', 'replaced']) + + settleSleep(true) + expect(stops.isReversibleStopInFlight('pty-1')).toBe(false) + settleRestart(false) + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + }) + + it('keeps a landed stop when a later stop of the same process fails', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-1', 'reversible', 'inc-1')(true) + + stops.mark('pty-1', 'reversible', 'inc-1')(false) + + expect(stops.claimExit('pty-1', 'inc-1')).toEqual(['reversible']) + }) + + it('lets a new process on the id supersede a landed stop no exit pinned', () => { + const stops = new TerminalIntentionalStops() + stops.mark('pty-unpinned', 'reversible', null)(true) + stops.mark('pty-pinned', 'reversible', 'inc-1')(true) + stops.mark('pty-in-flight', 'replaced', null) + + for (const ptyId of ['pty-unpinned', 'pty-pinned', 'pty-in-flight']) { + stops.noteSpawnCommit(ptyId) + } + + expect(stops.claimExit('pty-unpinned', null)).toEqual([]) + expect(stops.claimExit('pty-pinned', 'inc-1')).toEqual(['reversible']) + expect(stops.claimExit('pty-in-flight', null)).toEqual(['replaced']) + }) +}) diff --git a/src/main/runtime/terminal-intentional-stops.ts b/src/main/runtime/terminal-intentional-stops.ts new file mode 100644 index 00000000000..b89bc709e5f --- /dev/null +++ b/src/main/runtime/terminal-intentional-stops.ts @@ -0,0 +1,122 @@ +import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' + +/** + * Why main stopped a PTY on purpose. Both kinds keep the pane's binding through the exit: + * - `reversible`: sleep or hibernation; the binding is the wake hint. + * - `replaced`: a restart stops the old process so a new one can take the pane. + */ +export type TerminalIntentionalStopKind = 'reversible' | 'replaced' + +type IntentionalStopOwners = { inFlight: number; stopped: boolean } + +type IntentionalStop = { + /** Null until known; the first exit that claims the stop pins it to that process. */ + incarnationId: string | null + /** Why per kind: overlapping stops of different kinds each hold their own label on the exit. */ + ownersByKind: Map + expiryTimer?: ReturnType +} + +const NO_INTENTIONAL_STOP: readonly TerminalIntentionalStopKind[] = [] + +function hasInFlightOwners(stop: IntentionalStop): boolean { + return [...stop.ownersByKind.values()].some((owners) => owners.inFlight > 0) +} + +/** The one register of PTY stops main made on purpose, read by every exit path. */ +export class TerminalIntentionalStops { + private readonly stopsByPtyId = new Map() + + /** Registers one owner's stop; call the result once with whether the stop landed. */ + mark( + ptyId: string, + kind: TerminalIntentionalStopKind, + incarnationId: string | null + ): (stopped: boolean) => void { + let stop = this.stopsByPtyId.get(ptyId) + if (stop && this.joins(stop, incarnationId)) { + clearTimeout(stop.expiryTimer) + stop.expiryTimer = undefined + stop.incarnationId ??= incarnationId + } else { + clearTimeout(stop?.expiryTimer) + stop = { incarnationId, ownersByKind: new Map() } + this.stopsByPtyId.set(ptyId, stop) + } + const owners = stop.ownersByKind.get(kind) ?? { inFlight: 0, stopped: false } + owners.inFlight += 1 + stop.ownersByKind.set(kind, owners) + const owned = stop + let settled = false + return (stopped) => { + if (settled || this.stopsByPtyId.get(ptyId) !== owned) { + return + } + settled = true + owners.stopped ||= stopped + owners.inFlight -= 1 + if (owners.inFlight === 0 && !owners.stopped) { + owned.ownersByKind.delete(kind) + } + this.settleIfIdle(ptyId, owned) + } + } + + /** The kinds of stop this exit ends; empty when the process was not stopped on purpose. */ + claimExit( + ptyId: string, + exitIncarnationId: string | null | undefined + ): readonly TerminalIntentionalStopKind[] { + const stop = this.stopsByPtyId.get(ptyId) + if (!stop) { + return NO_INTENTIONAL_STOP + } + if (stop.incarnationId && exitIncarnationId && stop.incarnationId !== exitIncarnationId) { + return NO_INTENTIONAL_STOP + } + stop.incarnationId ??= exitIncarnationId ?? null + return [...stop.ownersByKind.keys()] + } + + /** Whether a stop of this PTY that may still be undone is in flight. */ + isReversibleStopInFlight(ptyId: string): boolean { + return (this.stopsByPtyId.get(ptyId)?.ownersByKind.get('reversible')?.inFlight ?? 0) > 0 + } + + /** A process committed on this id. A landed stop's process is dead, so an entry no exit ever + * pinned could otherwise claim the new process's exit as the stop. */ + noteSpawnCommit(ptyId: string): void { + const stop = this.stopsByPtyId.get(ptyId) + if (stop && stop.incarnationId === null && !hasInFlightOwners(stop)) { + clearTimeout(stop.expiryTimer) + this.stopsByPtyId.delete(ptyId) + } + } + + // Why: a settled entry joins only its own known process, so an id reused by a process whose + // incarnation is not yet known never inherits the old stop. + private joins(stop: IntentionalStop, incarnationId: string | null): boolean { + if (stop.incarnationId !== null && stop.incarnationId === incarnationId) { + return true + } + return hasInFlightOwners(stop) && (stop.incarnationId === null || incarnationId === null) + } + + private settleIfIdle(ptyId: string, stop: IntentionalStop): void { + if (hasInFlightOwners(stop)) { + return + } + if (stop.ownersByKind.size === 0) { + this.stopsByPtyId.delete(ptyId) + return + } + // Why a window: an SSH exit can arrive after the stop settles, and a synthetic exit can be + // followed by the provider's own; both describe the same stopped process. + stop.expiryTimer = setTimeout(() => { + if (this.stopsByPtyId.get(ptyId) === stop) { + this.stopsByPtyId.delete(ptyId) + } + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + stop.expiryTimer.unref?.() + } +} diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 3a6470b41f9..b79ea7a3270 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -92,9 +92,9 @@ describe('terminal interactive-wait visibility (STA-4513, STA-3714)', () => { await expect( assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) ).rejects.toThrow('terminal_guard_permission') - await expect(runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble')).rejects.toThrow( - 'agent_prompt_blocked' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'coordinator preamble', { inputKind: 'driving' }) + ).rejects.toThrow('agent_prompt_blocked') }) it('lets a dispatch preamble through once the same lane is working', async () => { diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index 9db883529a0..f479be9e47a 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -21,6 +21,7 @@ import { setTerminalViewAttributes } from './terminal-view-attribute-store' import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' const settingsState = { terminalMainSideEffectAuthority: true as boolean, @@ -58,9 +59,11 @@ type RendererBufferStub = { data: string; cols: number; rows: number } function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) { const runtime = new OrcaRuntimeService(store) const replies: { ptyId: string; data: string }[] = [] + const inputKinds: TerminalInputKind[] = [] runtime.setPtyController({ - write: (ptyId, data) => { + write: (ptyId, data, inputKind) => { replies.push({ ptyId, data }) + inputKinds.push(inputKind) return true }, kill: () => true, @@ -74,7 +77,7 @@ function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = } : {}) }) - return { runtime, replies } + return { runtime, replies, inputKinds } } /** Awaits the per-PTY emulator writeChain so queued chunk links (and the @@ -146,7 +149,7 @@ describe('reply parity for hidden-dropped chunks', () => { ['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']], ['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']] ])('%s', async (_label, chunk, expectedReplies) => { - const { runtime, replies } = createResponderRuntime() + const { runtime, replies, inputKinds } = createResponderRuntime() markHiddenRendererPty('pty-q') runtime.onPtyData('pty-q', chunk, Date.now()) @@ -154,6 +157,8 @@ describe('reply parity for hidden-dropped chunks', () => { expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true) + // Why: a reply written as driving input would read the untouched run as typed. + expect(inputKinds).toEqual(expectedReplies.map(() => 'query-reply')) }) it.each([ diff --git a/src/main/runtime/terminal-run-facts-input.test.ts b/src/main/runtime/terminal-run-facts-input.test.ts new file mode 100644 index 00000000000..a1f26d9b686 --- /dev/null +++ b/src/main/runtime/terminal-run-facts-input.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import { writePtyFromRuntimeController } from '../ipc/pty/runtime/operations' +import { getLocalPtyProvider, setLocalPtyProvider } from '../ipc/pty/provider/registry' +import type { IPtyProvider } from '../providers/types' +import { settledWriteStub } from '../providers/settled-pty-write-stub' +import { OrcaRuntimeService } from './orca-runtime' +import type { RuntimePtyController } from './runtime-pty-controller-contract' +import { writeOrchestrationPointerWithSettlement } from './orchestration/mailbox-pointer-pty-write' +import { sendTerminalStreamInput } from './rpc/methods/terminal/terminal-input-delivery' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' +import { + pasteWorktreeStartupDraftWhenReady, + sendWorktreeStartupFollowupWhenReady, + type WorktreeStartupReadinessHost +} from './runtime-worktree-startup-readiness' + +vi.mock('../git/worktree', () => { + const worktrees = [ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/run-facts', + isBare: false, + isMainWorktree: false + } + ] + return { + listWorktrees: vi.fn().mockResolvedValue(worktrees), + listWorktreesStrict: vi.fn().mockResolvedValue(worktrees) + } +}) + +const PTY_ID = 'pty-run-facts-input' +const priorProvider = getLocalPtyProvider() + +type FreshRun = { + runtime: OrcaRuntimeService + controller: RuntimePtyController + handle: string + /** The kind each provider write was sent with, and whether input was recorded by then. */ + writes: { data: string; inputRecorded: boolean }[] + kinds: TerminalInputKind[] + firstUserInputAt: () => number | null +} + +/** A fresh shell whose controller writes go through main's real write funnel to a fake provider. */ +async function createFreshRun(): Promise { + const runtime = new OrcaRuntimeService(makeStore() as never) + const firstUserInputAt = (): number | null => + runtime.terminalRunFacts.read(PTY_ID, null).firstUserInputAt + const writes: FreshRun['writes'] = [] + const kinds: TerminalInputKind[] = [] + const write = (_id: string, data: string): boolean => { + writes.push({ data, inputRecorded: firstUserInputAt() !== null }) + if (data.endsWith('\r')) { + runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', Date.now()) + } + return true + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write funnel calls only write and writeWithSettlement on the provider. + setLocalPtyProvider({ + write, + writeWithSettlement: settledWriteStub(write) + } as unknown as IPtyProvider) + const controller: RuntimePtyController = { + spawn: async () => ({ id: PTY_ID }), + write: (id, data, inputKind) => { + kinds.push(inputKind) + return writePtyFromRuntimeController({ runtime }, id, data, inputKind) + }, + writeWithSettlement: (id, data, inputKind) => { + kinds.push(inputKind) + return writePtyFromRuntimeController({ runtime }, id, data, inputKind, { + waitForSettlement: true + }) + }, + kill: () => true, + getForegroundProcess: async () => null + } + runtime.setPtyController(controller) + const terminal = await runtime.createTerminal('path:/tmp/worktree-a', { launchAgent: 'aider' }) + runtime.terminalRunFacts.recordSpawnCommit({ id: PTY_ID }) + return { runtime, controller, handle: terminal.handle, writes, kinds, firstUserInputAt } +} + +afterEach(() => { + vi.useRealTimers() + setLocalPtyProvider(priorProvider) +}) + +describe('run facts: the controller write funnel', () => { + it('records terminal.send input before the write that could end the process', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal( + run.handle, + { text: 'exit', enter: true }, + { inputKind: 'driving' } + ) + + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true, true]) + }) + + it('records stream input from a client', async () => { + const run = await createFreshRun() + + await sendTerminalStreamInput(run.runtime, { + terminal: run.handle, + text: 'l', + client: undefined, + isMobile: false + }) + + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records a dispatched agent prompt before its first write', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + + const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'review this', { + inputKind: 'driving' + }) + await vi.runAllTimersAsync() + await submission.catch(() => undefined) + + expect(run.firstUserInputAt()).not.toBeNull() + expect(run.writes[0]?.inputRecorded).toBe(true) + }) + + it('reads a run launched with a prompt as untyped until someone drives it', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + + const submission = run.runtime.sendTerminalAgentPrompt(run.handle, 'start here', { + inputKind: 'launch' + }) + await vi.runAllTimersAsync() + await submission.catch(() => undefined) + + expect(run.writes.length).toBeGreaterThan(0) + expect(run.firstUserInputAt()).toBeNull() + + await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'driving' }) + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records a mailbox pointer, which drives the running agent', async () => { + const run = await createFreshRun() + + await writeOrchestrationPointerWithSettlement({ + ptyId: PTY_ID, + data: 'You have 1 unread message.', + controller: run.controller + }) + + expect(run.kinds).toEqual(['driving']) + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records nothing for a client query reply', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal(run.handle, { text: 'y' }, { inputKind: 'query-reply' }) + + expect(run.firstUserInputAt()).toBeNull() + }) + + it.each([ + ['a terminal reply', '\x1b[3;4R'], + ['focus reports', '\x1b[I\x1b[O'], + ['the focus-in a desktop sends on reattaching a remote pane', '\x1b[I'] + ])('records nothing for stream input that is only %s', async (_label, text) => { + const run = await createFreshRun() + + await sendTerminalStreamInput(run.runtime, { + terminal: run.handle, + text, + client: undefined, + isMobile: false + }) + + expect(run.writes).toHaveLength(1) + expect(run.firstUserInputAt()).toBeNull() + }) + + it('records a reply mixed with a keystroke', async () => { + const run = await createFreshRun() + + await run.runtime.sendTerminal(run.handle, { text: '\x1b[3;4Rx' }, { inputKind: 'driving' }) + + expect(run.firstUserInputAt()).not.toBeNull() + }) + + it('records dashboard preview typing before the write that could end the process', async () => { + const run = await createFreshRun() + + await expect(run.runtime.writeTerminalPreviewInput(PTY_ID, 'exit\r')).resolves.toBe(true) + + expect(run.writes.map((write) => write.inputRecorded)).toEqual([true]) + }) + + it('records nothing for dashboard preview bytes that are only a reply or focus reports', async () => { + const run = await createFreshRun() + + await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[3;4R') + await run.runtime.writeTerminalPreviewInput(PTY_ID, '\x1b[O\x1b[I') + + expect(run.writes).toHaveLength(2) + expect(run.firstUserInputAt()).toBeNull() + }) +}) + +describe('run facts: a created worktree’s startup writes', () => { + function readinessHost(run: FreshRun): WorktreeStartupReadinessHost { + return { + getPtyId: () => PTY_ID, + getForegroundProcess: async () => 'aider', + subscribeToData: () => () => {}, + // Why: bracketed paste enabled, then quiet, is the default draft-ready signal. + readRecentOutput: () => '\x1b[?2004h', + write: (ptyId, data, inputKind) => run.controller.write(ptyId, data, inputKind) + } + } + + it('reads a run whose only input was its create-time draft and follow-up as untyped', async () => { + vi.useFakeTimers() + const run = await createFreshRun() + const host = readinessHost(run) + + pasteWorktreeStartupDraftWhenReady(host, run.handle, { agent: 'aider', content: 'plan it' }) + sendWorktreeStartupFollowupWhenReady(host, run.handle, { + expectedProcess: 'aider', + prompt: 'and ship it' + }) + await vi.runAllTimersAsync() + + expect(run.kinds).toEqual(['launch', 'launch']) + expect(run.writes).toHaveLength(2) + expect(run.firstUserInputAt()).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts new file mode 100644 index 00000000000..e1982ebc968 --- /dev/null +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { TerminalRunFactsRegister } from './terminal-run-facts' + +describe('terminal run facts', () => { + it('reads a run main never saw committed as not fresh', () => { + expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ + freshSpawn: false, + firstUserInputAt: null + }) + }) + + it('keeps the first user input across later input and a re-registration of the same process', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + facts.recordInput('pty-1', 'driving', 'ls\r', 200) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: true, firstUserInputAt: 100 }) + }) + + it('starts a new process clean', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-2' }, { tabId: 'source-tab' }) + + expect(facts.read('pty-1', 'inc-2')).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(facts.read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, firstUserInputAt: null }) + }) + + it('never reads a reattached process as fresh', () => { + const facts = new TerminalRunFactsRegister() + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.read('pty-1', 'inc-1').freshSpawn).toBe(false) + }) + + it('starts clean when a commit carries no incarnation to tell it from a new process', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', isReattach: true }) + + expect(facts.read('pty-1', null)).toEqual({ freshSpawn: false, firstUserInputAt: null }) + }) + + it.each([ + ['a launch write', 'launch', 'echo startup\r'], + ['a query reply', 'query-reply', '\x1b[1;1R'], + ['driving bytes that are only a terminal reply', 'driving', '\x1b[1;1R'], + ['driving bytes that are only focus reports', 'driving', '\x1b[I\x1b[O'] + ] as const)('records nothing for %s', (_label, inputKind, data) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + + facts.recordInput('pty-1', inputKind, data, 100) + + expect(facts.read('pty-1', 'inc-1').firstUserInputAt).toBeNull() + }) +}) diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts new file mode 100644 index 00000000000..1fb3e411b6c --- /dev/null +++ b/src/main/runtime/terminal-run-facts.ts @@ -0,0 +1,90 @@ +import { + spawnCommitBindingOrigin, + type PtySpawnCommitOrigin +} from '../persistence/loading-store/pty-binding-span' +import { isTerminalQueryReply } from '../../shared/terminal-query-reply' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' + +export type TerminalRunFacts = { + /** This process was started for its pane, not reattached, adopted or cold-restored. */ + freshSpawn: boolean + /** When input first drove this process, from any client or driver; null if none has. */ + firstUserInputAt: number | null +} + +// Why: a paired client's xterm answers focus changes (CSI I / CSI O) through input that carries no +// provenance; the desktop renderer already excludes them via xterm's user-input signal. +// oxlint-disable-next-line no-control-regex -- focus reports are ESC-framed sequences by definition. +const TERMINAL_FOCUS_REPORTS_ONLY_RE = new RegExp('^(?:\\u001b\\[[IO])+$') + +/** Input with no provenance that no person typed: a whole terminal reply or only focus reports. */ +function isUntypedTerminalInput(payload: string): boolean { + return isTerminalQueryReply(payload) || TERMINAL_FOCUS_REPORTS_ONLY_RE.test(payload) +} + +export type TerminalSpawnCommit = Parameters[0] & { + id: string + incarnationId?: string + coldRestore?: object +} + +/** A cold restore starts a new process for a pane that had one, so it is never fresh. */ +type TerminalRunSpawnOrigin = PtySpawnCommitOrigin | 'cold-restore' + +type TerminalRunRecord = { + incarnationId: string | null + spawnOrigin: TerminalRunSpawnOrigin + firstUserInputAt: number | null +} + +/** Main's per-process facts about one PTY run, keyed by the incarnation they describe. */ +export class TerminalRunFactsRegister { + private readonly runsByPtyId = new Map() + + /** Once per process: a re-registration of the same incarnation keeps its facts. Without an + * incarnation a commit cannot be told from a new process, so it starts clean. */ + recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { + const incarnationId = commit.incarnationId ?? null + if ( + incarnationId !== null && + this.runsByPtyId.get(commit.id)?.incarnationId === incarnationId + ) { + return + } + const origin = spawnCommitBindingOrigin(commit, expectedSourceBinding) + this.runsByPtyId.set(commit.id, { + incarnationId, + spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, + firstUserInputAt: null + }) + } + + /** The one record point both write funnels call just before the provider write, because input + * such as `exit` can end the process before the write returns. The payload check backs up a + * writer that labels a reply or focus report as driving. */ + recordInput(ptyId: string, inputKind: TerminalInputKind, data: string, now = Date.now()): void { + if (inputKind !== 'driving') { + return + } + const run = this.runsByPtyId.get(ptyId) + if (run && run.firstUserInputAt === null && !isUntypedTerminalInput(data)) { + run.firstUserInputAt = now + } + } + + /** A run main never saw committed reads as not fresh, which keeps today's close-on-exit. */ + read(ptyId: string, incarnationId: string | null | undefined): TerminalRunFacts { + const run = this.runsByPtyId.get(ptyId) + if (!run || (run.incarnationId && incarnationId && run.incarnationId !== incarnationId)) { + return { freshSpawn: false, firstUserInputAt: null } + } + return { + freshSpawn: run.spawnOrigin === 'spawn' || run.spawnOrigin === 'split', + firstUserInputAt: run.firstUserInputAt + } + } + + delete(ptyId: string): void { + this.runsByPtyId.delete(ptyId) + } +} diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index b7c5ed95068..a25ed9817fb 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -96,9 +96,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { const probe = vi.fn(async () => false) const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(probe).toHaveBeenCalledWith(STALE_PTY_ID) expect(write).not.toHaveBeenCalled() @@ -108,9 +108,9 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { const probe = vi.fn(async () => false) const { runtime, handle, write } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminalAgentPrompt(handle, 'do the thing', { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(write).not.toHaveBeenCalled() }) @@ -120,12 +120,14 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { probePtyLiveness: async () => null }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ handle, accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('treats a throwing probe as unknown and proceeds', async () => { @@ -135,11 +137,13 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { } }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('proceeds when the probe answers live (restored session before its pane remounts)', async () => { @@ -147,21 +151,25 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { probePtyLiveness: async () => true }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('proceeds unchanged when the controller exposes no probe', async () => { const { runtime, handle, write } = await makeRuntimeWithLeafHandle({}) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('never probes when the provider synchronously knows the id (live pty)', async () => { @@ -171,24 +179,26 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { hasPty: (ptyId) => ptyId === STALE_PTY_ID }) - await expect(runtime.sendTerminal(handle, { text: 'ping' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'ping' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(probe).not.toHaveBeenCalled() - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping', 'driving') }) it('reuses a proven-absent verdict across repeated sends instead of re-probing', async () => { const probe = vi.fn(async () => false) const { runtime, handle } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) - await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow( - 'terminal_not_writable' - ) - await expect(runtime.sendTerminal(handle, { text: 'b' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') + await expect( + runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') expect(probe).toHaveBeenCalledTimes(1) }) @@ -201,17 +211,19 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { hasPty: (ptyId) => livePtyIds.has(ptyId) }) - await expect(runtime.sendTerminal(handle, { text: 'a' })).rejects.toThrow( - 'terminal_not_writable' - ) + await expect( + runtime.sendTerminal(handle, { text: 'a' }, { inputKind: 'driving' }) + ).rejects.toThrow('terminal_not_writable') // Same id recreated by a fresh spawn: provider knowledge must beat the verdict. livePtyIds.add(STALE_PTY_ID) - await expect(runtime.sendTerminal(handle, { text: 'b' })).resolves.toMatchObject({ + await expect( + runtime.sendTerminal(handle, { text: 'b' }, { inputKind: 'driving' }) + ).resolves.toMatchObject({ accepted: true }) expect(probe).toHaveBeenCalledTimes(1) - expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b') + expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'b', 'driving') }) }) diff --git a/src/main/runtime/terminal-surface-close.ts b/src/main/runtime/terminal-surface-close.ts index 65756f12e4f..0feee12402c 100644 --- a/src/main/runtime/terminal-surface-close.ts +++ b/src/main/runtime/terminal-surface-close.ts @@ -1,4 +1,9 @@ +import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-session-contracts' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { + hasClosedTerminalTabRecord, + recordClosedTerminalTabTombstone +} from '../../shared/closed-terminal-tab-tombstones' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode @@ -91,12 +96,18 @@ export type TerminalSurfaceCloseResult = WorkspaceSessionTerminalTabCloseResult * The membership half of every explicit terminal close: removes a tab, or one pane of a split * tab, and advances the repo's topology revision so a stale renderer save cannot restore it. * A pane close only ever removes that pane; the resolution says why anything else was a no-op. + * Every tab close is recorded in the session it was removed from, the owning host's partition. */ export function closeTerminalSurfaceInWorkspaceSession( session: WorkspaceSessionState, worktreeId: string, target: TerminalSurfaceCloseTarget, - options: { force?: boolean; paneIncarnationId?: string } = {} + options: { + force?: boolean + paneIncarnationId?: string + reason: RuntimeSessionTabCloseReason + now?: number + } ): TerminalSurfaceCloseResult { if (target.kind === 'pane') { const layout = session.terminalLayoutsByTabId[target.tabId] @@ -128,20 +139,48 @@ export function closeTerminalSurfaceInWorkspaceSession( const result = closeTerminalTabInWorkspaceSession(session, worktreeId, target.tabId, { force: options.force }) + if (result.pinned) { + return { ...result, resolution: 'tab' } + } + // Why a tab this session never listed is still recorded: its spawn may commit later and graft it. + const recorded: WorkspaceSessionState = { + ...result.session, + closedTerminalTabTombstonesByTabId: recordClosedTerminalTabTombstone( + result.session.closedTerminalTabTombstonesByTabId, + target.tabId, + { worktreeId, reason: options.reason }, + options.now ?? Date.now() + ) + } return { ...result, - ...(result.closed - ? { session: advanceTerminalTopologyRevision(result.session, worktreeId) } - : {}), + session: result.closed ? advanceTerminalTopologyRevision(recorded, worktreeId) : recorded, resolution: 'tab' } } +/** How one close commits; `reason` is recorded only when the close resolves to the whole tab. */ +export type TerminalSurfaceCloseOptions = { + allowMissing?: boolean + force?: boolean + reason?: RuntimeSessionTabCloseReason + /** The desktop renderer's own close: its layout owner already removed the tab. Goes away with + * D1, once main owns the terminal layout. */ + closedByLayoutOwner?: boolean +} + +/** The desktop renderer's close intent as its IPC delivers it; main alone passes 'pty-exit'. */ +export type RendererTerminalClose = { + worktreeId: string + target: TerminalSurfaceCloseTarget + reason?: 'user' | 'cleanup' +} + /** What one close's durable mutation reads and writes, resolved when the writer admits it. */ export type TerminalSurfaceCloseCommit = { worktreeId: string target: TerminalSurfaceCloseTarget - options: { allowMissing?: boolean; force?: boolean } + options: TerminalSurfaceCloseOptions /** The session as it was when the close was asked, before the writer admitted it. */ requestedSession: WorkspaceSessionState | null | undefined /** The tab's owner identity still matches the one the close was asked against. */ @@ -175,12 +214,23 @@ export function terminalSurfaceCloseMutation( } const result = closeTerminalSurfaceInWorkspaceSession(session, commit.worktreeId, target, { force: commit.options.force, - paneIncarnationId + paneIncarnationId, + reason: commit.options.reason ?? 'user' }) if (result.pinned) { return { value: new Error('terminal_tab_pinned'), persist: false } } if (!result.closed) { + // Why: a tab this session never listed still records its close, so its late spawn is refused; + // an existing record (an echo of that close) needs no second write. + if ( + commit.options.allowMissing && + target.kind === 'tab' && + !hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, target.tabId) + ) { + commit.setSession(result.session, hostId) + return { value: undefined } + } return { value: commit.options.allowMissing ? undefined : new Error('tab_not_found'), persist: false diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 454739471b7..bb102a3e7b8 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -1,3 +1,4 @@ +import { isQoderComposerReady } from './qoder-terminal-readiness' import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo' import { detectAgentStatusFromTitle, @@ -61,6 +62,9 @@ export function isKnownReadyPromptBody( agent: TuiAgent | null, readScreenLines: () => readonly string[] | null ): boolean { + if (agent === 'qoder') { + return isQoderComposerReady(readScreenLines()) + } if (isKnownReadyPromptPreview(waitText)) { return true } diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 53fcb42285b..ec88c3368a3 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { evaluateTuiIdle, + isTuiIdleReadyVerdict, hasQuietMuseReadyPrompt, nameOnlyIdleNeedsCorroboration, type TuiIdleEvaluationInput, @@ -174,3 +175,41 @@ describe('nameOnlyIdleNeedsCorroboration', () => { expect(nameOnlyIdleNeedsCorroboration(null, 'claude ~/p/repo')).toBe(true) }) }) + +describe('a DSH pane settles tui-idle on its own hook', () => { + const base = { + record: { lastAgentStatus: null, lastOutputAt: null, lastOscTitle: '\u2726 \u{1F40B} repo' }, + rendererTitle: undefined, + readPositiveBodyEvidence: () => false, + readMuseReadyBodyEvidence: () => false, + readTailBlockedReason: () => null, + agent: 'dsh' as const, + firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() }, + quiescenceMs: 1_000 + } satisfies TuiIdleEvaluationInput + + const ready = (over: Partial = {}) => + isTuiIdleReadyVerdict(evaluateTuiIdle({ ...base, ...over })) + + it('settles on a fresh first-party done', () => { + // The regression: DSH's title carries no idle (its rest glyph is Gemini's working one), + // so every title-reading tier failed and `terminal wait --for tui-idle` ran to timeout + // against an already-ready composer. + expect(ready()).toBe(true) + }) + + it('does not settle while the same pane reports working', () => { + expect(ready({ firstPartyStatus: { state: 'working', updatedAt: Date.now() } })).toBe(false) + }) + + it('does not settle on a stale done', () => { + expect( + ready({ firstPartyStatus: { state: 'done', updatedAt: Date.now() - 31 * 60 * 1000 } }) + ).toBe(false) + }) + + it('leaves other agents on the title lanes', () => { + // Scoped on purpose: an agent whose hooks report child turns can emit `done` mid-turn. + expect(ready({ agent: 'claude' })).toBe(false) + }) +}) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 7acfc610894..87027ab4bc3 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -1,5 +1,8 @@ import type { AgentStatus } from '../../shared/agent-detection' -import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-freshness' +import { + AGENT_STATUS_STALE_AFTER_MS, + isFreshNonDoneAgentStatus +} from '../../shared/agent-status-freshness' import type { AgentStatusState } from '../../shared/agent-status-types' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title' @@ -68,6 +71,34 @@ export function hasExplicitIdleTitle( return false } +/** + * Tier 1, first-party: the agent's own hook says the turn ENDED. + * + * Why DSH needs its own lane: the other tiers all read the title, and DSH cannot carry idle + * there. Its rest prefix is `✦`, which is Gemini's WORKING glyph, so the title detector + * deliberately reports no status for a DSH pane at all (see agent-title-status.ts) — which + * left `tui-idle` with nothing to settle on, and a supervised worker waiting on a ready + * composer until its timeout. + * + * Why a hook `done` is trustworthy here where a title would not be: it is the agent's own + * account of its own turn, and `normalizeDshEvent` drops SubagentStart/SubagentStop, so a + * `done` row for a DSH pane is the LEAD's, never a child's finishing early. + * + * Scoped rather than general: for agents whose hooks do report child turns, a `done` row + * can arrive mid-turn, and settling on it is exactly the #6011 class this file exists to + * prevent. + */ +export function hasFreshDoneFirstPartyStatus( + agent: TuiAgent | null | undefined, + status: FirstPartyAgentStatus, + staleAfterMs = AGENT_STATUS_STALE_AFTER_MS +): boolean { + if (agent !== 'dsh' || status?.state !== 'done') { + return false + } + return Date.now() - status.updatedAt <= staleAfterMs +} + /** Tier 2: the agent's own status stream says this turn is still open. */ export function hasFreshWorkingFirstPartyStatus(status: FirstPartyAgentStatus): boolean { return isFreshNonDoneAgentStatus(status ?? undefined) @@ -203,11 +234,28 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { if (blockedReason) { return { kind: 'blocked', reason: blockedReason } } + // Qoder publishes "Ready" before its trust dialog is dismissed; only its composer proves input is live. + if (input.agent === 'qoder') { + if ( + hasFreshWorkingFirstPartyStatus(input.firstPartyStatus) || + input.record.lastAgentStatus === 'working' + ) { + return WORKING + } + return input.readPositiveBodyEvidence() + ? READY_STRONG + : { kind: 'pending', quietForeground: false } + } // Why the title before the body: both are tier 1, so either settles, but the title is a // memoized lookup and the body is a fresh multi-KB scan. Same verdict, cheaper order. if (hasExplicitIdleTitle(input.record, input.rendererTitle) || input.readPositiveBodyEvidence()) { return READY_STRONG } + // Why beside the title lane, not after the veto: both are tier 1, and a first-party `done` + // and a fresh `working` cannot both hold — the same row carries one state. + if (hasFreshDoneFirstPartyStatus(input.agent, input.firstPartyStatus)) { + return READY_STRONG + } if (hasFreshWorkingFirstPartyStatus(input.firstPartyStatus)) { // Why blocked/waiting stays pending: the agent says it is waiting on the user, which is // when a dialog is on screen, so the screen read must still run. @@ -289,7 +337,7 @@ export function ptyTuiIdleEvidence( record: pty, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), readPositiveBodyEvidence: () => - source.getAdoptedPtyIdleStatus(pty) === 'idle' || + (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)), readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText()), agent, diff --git a/src/main/sqlite/sqlite-read-failure.ts b/src/main/sqlite/sqlite-read-failure.ts index 6cdb75f343a..6919bf9e9cb 100644 --- a/src/main/sqlite/sqlite-read-failure.ts +++ b/src/main/sqlite/sqlite-read-failure.ts @@ -6,7 +6,9 @@ /** Primary result codes; extended codes pack the primary code in the low byte. */ const SQLITE_BUSY = 5 const SQLITE_LOCKED = 6 +const SQLITE_CORRUPT = 11 const SQLITE_CANTOPEN = 14 +const SQLITE_NOTADB = 26 // Shared with the Codex index-heal pass, which only ever sees a relayed message // string (app-server RPC drops `errcode`), so message matching is not optional. @@ -20,6 +22,23 @@ function primaryErrcode(error: unknown): number | null { return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null } +/** node:sqlite marks its errors `ERR_SQLITE_ERROR`; Bun names its class `SQLiteError`. */ +function isSqliteDriverError(error: unknown): boolean { + return ( + error instanceof Error && + (('code' in error && error.code === 'ERR_SQLITE_ERROR') || error.name === 'SQLiteError') + ) +} + +/** True only when SQLite itself reports the database damaged or not a database at all. */ +export function isSqliteCorruption(error: unknown): boolean { + if (!isSqliteDriverError(error)) { + return false + } + const errcode = primaryErrcode(error) + return errcode === SQLITE_CORRUPT || errcode === SQLITE_NOTADB +} + function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/src/main/ssh/ssh-pending-pty-kill-replay.test.ts b/src/main/ssh/ssh-pending-pty-kill-replay.test.ts index 7f682c6c1b6..95296aa6434 100644 --- a/src/main/ssh/ssh-pending-pty-kill-replay.test.ts +++ b/src/main/ssh/ssh-pending-pty-kill-replay.test.ts @@ -134,6 +134,28 @@ describe('replayPendingSshPtyKills', () => { expect(terminated).toEqual(['pty-1']) }) + // An offline close across a relaunch never learned the incarnation; the epoch-scoped id is the fence. + it('replays an epoch-scoped stop that carries no incarnation', async () => { + const relayPtyId = 'pty2:epoch-a:4' + const { store, cleared, terminated } = createStoreStub([ + { ptyId: relayPtyId, intent: { requestedAt: NOW, attempts: 0 } } + ]) + const { provider, shutdown } = createProviderStub([{ relayPtyId, incarnationId: 'inc-live' }]) + await replayPendingSshPtyKills({ + targetId: TARGET, + store, + provider, + shouldContinue: () => true, + now: () => NOW + }) + expect(shutdown).toHaveBeenCalledWith(`ssh:ssh-1@@${relayPtyId}`, { + immediate: true, + expectedIncarnationId: undefined + }) + expect(cleared).toEqual([relayPtyId]) + expect(terminated).toEqual([relayPtyId]) + }) + // #16970: a redeployed relay renumbers from pty-1, so this id now names someone else's shell. it('refuses to kill a recycled relay id and expires the lease that named it', async () => { const { store, cleared, terminated, expired, recycled } = createStoreStub([ diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index 3c405cd5dff..ce223ae0b9c 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -458,13 +458,21 @@ describe('SshRelaySession reconnect incarnation ordering', () => { }) it.each([ - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'local' }, - { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'host' }, - { relay: 'legacy', incarnationId: undefined, tombstonePartition: 'local' }, - { relay: 'legacy', incarnationId: undefined, tombstonePartition: 'host' } + { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'local', retiredBy: 'surface' }, + { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'host', retiredBy: 'surface' }, + { + relay: 'legacy', + incarnationId: undefined, + tombstonePartition: 'local', + retiredBy: 'surface' + }, + { relay: 'legacy', incarnationId: undefined, tombstonePartition: 'host', retiredBy: 'surface' }, + // A closed tab whose pane is in no tab: the close record is the backstop. + { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'local', retiredBy: 'close' }, + { relay: 'current', incarnationId: 'inc-1', tombstonePartition: 'host', retiredBy: 'close' } ])( - 'suppresses a $tombstonePartition-partition retired surface from a $relay relay', - async ({ incarnationId, tombstonePartition }) => { + 'suppresses a $tombstonePartition-partition $retiredBy retirement from a $relay relay', + async ({ incarnationId, tombstonePartition, retiredBy }) => { const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() const attachForReconnect = vi.fn().mockResolvedValue({ ...(incarnationId ? { incarnationId } : {}), @@ -514,10 +522,16 @@ describe('SshRelaySession reconnect incarnation ordering', () => { } } } + const closedTab: ReturnType = { + ...getDefaultWorkspaceSession(), + closedTerminalTabTombstonesByTabId: { [tabId]: { closedAt: Date.now(), worktreeId } } + } vi.mocked(mockStore.getWorkspaceSession).mockImplementation((hostId) => - (hostId ? 'host' : 'local') === tombstonePartition - ? sessionWithTombstone - : getDefaultWorkspaceSession() + (hostId ? 'host' : 'local') !== tombstonePartition + ? getDefaultWorkspaceSession() + : retiredBy === 'close' + ? closedTab + : sessionWithTombstone ) const runtime = { registerPty: vi.fn(), onPtySpawned: vi.fn() } const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 76b7bfb238c..e7911691261 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -119,6 +119,7 @@ import { } from '../../shared/ssh-ai-vault-relay' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' +import { hasClosedTerminalTabRecord } from '../../shared/closed-terminal-tab-tombstones' import { openSshPtyConsumerSession, type OpenSshPtyConsumerSessionOptions, @@ -2752,9 +2753,19 @@ export class SshRelaySession { if (hasLiveCurrentBinding) { return false } - return [lease.tabId, ...currentTabIds] - .filter((tabId) => isValidTerminalTabId(tabId)) - .some(tombstoneMatches) + // Why only when no tab holds the leaf: a pane moved out of the tab before it closed lives on. + const leaseTabId = lease.tabId + const closedByRecord = + currentTabIds.length === 0 && + candidates.some((candidate) => + hasClosedTerminalTabRecord(candidate?.closedTerminalTabTombstonesByTabId, leaseTabId) + ) + return ( + closedByRecord || + [lease.tabId, ...currentTabIds] + .filter((tabId) => isValidTerminalTabId(tabId)) + .some(tombstoneMatches) + ) } private async suppressRetiredReattachedPty( diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 7c538a990f1..8cd8fb6a251 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -50,7 +50,7 @@ export type AgentStatusApi = { export type AgentTrustApi = { markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' + preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' workspacePath: string connectionId?: string }) => Promise diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts index a9afa9c363d..9d87c62eb49 100644 --- a/src/preload/api/agent-trust-bridge.ts +++ b/src/preload/api/agent-trust-bridge.ts @@ -3,7 +3,7 @@ import type { PreloadApi } from '../api-types' export const agentTrustApi = { markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' + preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' workspacePath: string connectionId?: string }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index ca273aee07d..eb45c4dba68 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -3,6 +3,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PtyListedSession, PtySessionListScope } from '../../shared/pty-listed-session' import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' @@ -75,14 +76,15 @@ export type PtyApi = { /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> - write: (id: string, data: string) => void - writeAccepted: (id: string, data: string) => Promise + write: (id: string, data: string, inputKind: TerminalInputKind) => void + writeAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void resize: (id: string, cols: number, rows: number) => void claimViewport: (id: string, cols: number, rows: number) => void reportGeometry: (id: string, cols: number, rows: number) => void signal: (id: string, signal: string) => void clearBuffer: (id: string) => void + resetInputModes: (id: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void ackData: (id: string, charCount: number, processedChars?: number) => void @@ -228,6 +230,7 @@ export type PtyApi = { }) => void ) => () => void onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void + onResetInputModesRequest: (callback: (data: { ptyId: string }) => void) => () => void sendSerializedBuffer: ( requestId: string, snapshot: { diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index e9b19dbe43a..1f8639c58be 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -71,11 +72,11 @@ export const ptySessionControlApi = { /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> => ipcRenderer.invoke('pty:spawn', opts), - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) + write: (id: string, data: string, inputKind: TerminalInputKind): void => { + ipcRenderer.send('pty:write', { id, data, inputKind }) }, - writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), + writeAccepted: (id: string, data: string, inputKind: TerminalInputKind): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind }), onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => callback(payload) @@ -97,6 +98,9 @@ export const ptySessionControlApi = { clearBuffer: (id: string): void => { ipcRenderer.send('pty:clearBuffer', { id }) }, + resetInputModes: (id: string): void => { + ipcRenderer.send('pty:resetInputModes', { id }) + }, ackColdRestore: (id: string): void => { ipcRenderer.send('pty:ackColdRestore', { id }) }, diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index 49e2189fd53..557570c8eab 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -120,6 +120,11 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.on('pty:clearBuffer:request', listener) return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener) }, + onResetInputModesRequest: (callback: (data: { ptyId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => callback(data) + ipcRenderer.on('pty:resetInputModes:request', listener) + return () => ipcRenderer.removeListener('pty:resetInputModes:request', listener) + }, sendSerializedBuffer: ( requestId: string, snapshot: { diff --git a/src/preload/api/workspace-session-api.ts b/src/preload/api/workspace-session-api.ts index 434b3795df1..d621e5dc7db 100644 --- a/src/preload/api/workspace-session-api.ts +++ b/src/preload/api/workspace-session-api.ts @@ -24,6 +24,7 @@ export type WorkspaceSessionApi = { closeTerminalSurface: (args: { worktreeId: string target: TerminalSurfaceCloseTarget + reason?: 'user' | 'cleanup' }) => Promise flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null diff --git a/src/relay/ai-vault-opencode-reader.test.ts b/src/relay/ai-vault-opencode-reader.test.ts index 981ade1fd12..3d082205d5d 100644 --- a/src/relay/ai-vault-opencode-reader.test.ts +++ b/src/relay/ai-vault-opencode-reader.test.ts @@ -223,11 +223,11 @@ describe('relay OpenCode reader', () => { list: (args) => args.agent === 'opencode2' ? listOpenCode2SqliteSessions(args) - : listOpenCodeSqliteSessions(args), + : listOpenCodeSqliteSessions({ ...args, agent: 'opencode' }), parse: async (args) => args.agent === 'opencode2' ? parseOpenCode2SqliteSession(args) - : parseOpenCodeSqliteSession(args), + : parseOpenCodeSqliteSession({ ...args, agent: 'opencode' }), dispose() {} }) const provider = createRelayAiVaultFilesystemProvider({ diff --git a/src/relay/freebuff-status-projection.test.ts b/src/relay/freebuff-status-projection.test.ts new file mode 100644 index 00000000000..b10214ef801 --- /dev/null +++ b/src/relay/freebuff-status-projection.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { FreebuffStatusProjection } from './freebuff-status-projection' +import { createAgentStatusOscProcessor } from '../shared/agent-status-osc' + +const transcript = readFileSync( + join(import.meta.dirname, '../main/runtime/__fixtures__/freebuff-lifecycle.txt'), + 'utf8' +) + +describe('Freebuff relay status projection', () => { + it.each([1, 137, 4096, transcript.length])( + 'publishes host-observed states with %i-character PTY chunks', + (size) => { + const projection = new FreebuffStatusProjection(120, 40) + const parse = createAgentStatusOscProcessor() + const states: string[] = [] + try { + for (let index = 0; index < transcript.length; index += size) { + const raw = transcript.slice(index, index + size) + const projected = projection.project(raw) + // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. + expect(projected.replace(/\x1b\]9999;[^\x07]*\x07/g, '')).toBe(raw) + for (const payload of parse(projected).payloads) { + expect(payload.agentType).toBe('freebuff') + if (states.at(-1) !== payload.state) { + states.push(payload.state) + } + } + } + expect(states).toContain('working') + expect(states).toContain('waiting') + expect(states.at(-1)).toBe('done') + projection.resize(90, 30) + projection.dispose() + expect(projection.project('later output')).toBe('later output') + } finally { + projection.dispose() + } + } + ) +}) diff --git a/src/relay/freebuff-status-projection.ts b/src/relay/freebuff-status-projection.ts new file mode 100644 index 00000000000..6e8ea7d9de3 --- /dev/null +++ b/src/relay/freebuff-status-projection.ts @@ -0,0 +1,45 @@ +import { HeadlessEmulator } from '../main/daemon/headless-emulator' +import { + FreebuffScreenStatusTracker, + splitFreebuffScreenUpdates +} from '../shared/freebuff-screen-status' + +/** Emits the existing status OSC on the execution host, preserving the PTY's raw byte credit. */ +export class FreebuffStatusProjection { + private readonly emulator: HeadlessEmulator + private readonly tracker = new FreebuffScreenStatusTracker(true) + private disposed = false + + constructor(cols: number, rows: number) { + this.emulator = new HeadlessEmulator({ cols, rows, scrollback: 0 }) + } + + project(data: string): string { + if (this.disposed) { + return data + } + return splitFreebuffScreenUpdates(data, this.emulator.partialEscapeTailAnsi) + .map((chunk) => { + if (!this.emulator.writeSync(chunk)) { + return chunk + } + const status = this.tracker.observe(chunk, () => ({ + lines: this.emulator.getVisibleLines(), + alternate: this.emulator.isAlternateScreen + })) + return status ? `${chunk}\x1b]9999;${JSON.stringify(status)}\x07` : chunk + }) + .join('') + } + + resize(cols: number, rows: number): void { + if (!this.disposed) { + this.emulator.resize(cols, rows) + } + } + + dispose(): void { + this.disposed = true + this.emulator.dispose() + } +} diff --git a/src/relay/managed-hook-installer.ts b/src/relay/managed-hook-installer.ts index 3fa57dd5ed8..e88089f486a 100644 --- a/src/relay/managed-hook-installer.ts +++ b/src/relay/managed-hook-installer.ts @@ -6,7 +6,7 @@ import { import type { RelayDispatcher, RequestContext } from './dispatcher' import type { AgentHookTarget } from '../shared/agent-hook-types' import { isManagedAgentHookTarget } from '../shared/managed-agent-hook-targets' -import { parseClaudeCliVersion } from '../main/claude/claude-session-end-hook-capability' +import { parseClaudeCliVersion } from '../main/claude/claude-hook-event-versions' export type ManagedHookInstallSummary = { installers: number diff --git a/src/relay/pty-handler-output-streaming.test.ts b/src/relay/pty-handler-output-streaming.test.ts index af0a4cbdfaa..1030d98ca42 100644 --- a/src/relay/pty-handler-output-streaming.test.ts +++ b/src/relay/pty-handler-output-streaming.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +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' @@ -150,6 +152,34 @@ describe('PtyHandler', () => { expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, data: 'hello world' }) }) + it('publishes Freebuff host status without charging synthetic OSC bytes to the PTY', async () => { + let onData: ((data: string) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((callback: (data: string) => void) => { + onData = callback + }) + }) + await dispatcher.callRequest('pty.spawn', { launchAgent: 'freebuff', cols: 120, rows: 40 }) + const raw = readFileSync( + join(import.meta.dirname, '../main/runtime/__fixtures__/freebuff-trust.txt'), + 'utf8' + ) + 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 + }) + dispatcher.notify.mockClear() + onData!('more output') + vi.advanceTimersByTime(8) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', { id: PTY_1, data: 'more output' }) + }) + it('consumes capable startup queries before relay replay and fanout', async () => { let dataCallback: ((data: string) => void) | undefined const term = { diff --git a/src/relay/pty-handler-shell-recovery.test.ts b/src/relay/pty-handler-shell-recovery.test.ts index 0d34aa80ff9..54497cd1763 100644 --- a/src/relay/pty-handler-shell-recovery.test.ts +++ b/src/relay/pty-handler-shell-recovery.test.ts @@ -193,4 +193,17 @@ describe.each([ expect(published()).toBe(`${DYING_COMMAND}${COMMAND_DONE}${PROMPT}`) }) + + it('grounds replay, not the live stream, on Reset Terminal after an unhooked crash', async () => { + // Armed with no command end: nothing the host barrier could ground at. + const unhookedCrash = '\x1b[>1u\x1b[?1000h' + await stream(unhookedCrash, PROMPT) + + dispatcher.feed(requestFrame(4, 'pty.resetInputModes', { id: ptyId })) + await vi.advanceTimersByTimeAsync(50) + + // The client grounds its own view; a zero-raw span would not cross the credit window. + expect(published()).toBe(`${unhookedCrash}${PROMPT}`) + expect(await replay()).toBe(`${unhookedCrash}${PROMPT}${PROCESS_BOUNDARY_GROUND}`) + }) }) diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts index eff0f68f2fa..22993322a9c 100644 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ b/src/relay/pty-handler-spawn-environment.test.ts @@ -606,6 +606,54 @@ describe('PtyHandler', () => { expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1') }) + it('mirrors pane identity onto its scrub-safe aliases for a remote spawn', async () => { + // Why the relay and not just the shared helper: a remote pane's env is built here, and an + // agent whose harness drops KEY/TOKEN names (DSH) has nothing to attribute its hooks to. + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { ORCA_PANE_KEY: 'tab-1:0', ORCA_AGENT_LAUNCH_TOKEN: 't' } + }) + + const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(callArgs.env.ORCA_AGENT_PANE).toBe('tab-1:0') + expect(callArgs.env.ORCA_AGENT_LAUNCH).toBe('t') + expect(callArgs.env.ORCA_PANE_KEY).toBe('tab-1:0') + }) + + it("drops an alias the relay's own env carries when the spawn claims no identity", async () => { + // Why: the relay is itself startable from an Orca pane, so inheriting either name would + // attribute this pane's hooks to whichever row started the relay. + const previous = { + pane: process.env.ORCA_PANE_KEY, + launch: process.env.ORCA_AGENT_LAUNCH_TOKEN, + paneAlias: process.env.ORCA_AGENT_PANE + } + process.env.ORCA_PANE_KEY = 'relays-own-pane' + process.env.ORCA_AGENT_LAUNCH_TOKEN = 'relays-own-launch' + process.env.ORCA_AGENT_PANE = 'stale-alias' + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + } finally { + for (const [key, value] of [ + ['ORCA_PANE_KEY', previous.pane], + ['ORCA_AGENT_LAUNCH_TOKEN', previous.launch], + ['ORCA_AGENT_PANE', previous.paneAlias] + ] as const) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + + const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(callArgs.env.ORCA_PANE_KEY).toBeUndefined() + expect(callArgs.env.ORCA_AGENT_PANE).toBeUndefined() + expect(callArgs.env.ORCA_AGENT_LAUNCH).toBeUndefined() + }) + it('passes PTY and explicit launch identity to env augmenters', async () => { const seenContexts: { id: string diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index dab46035749..286bfdb89a7 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,3 +1,4 @@ +import { FreebuffStatusProjection } from './freebuff-status-projection' /* oxlint-disable max-lines */ import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -18,6 +19,7 @@ import { import { inspectPtyChildProcesses, processHasChildren } from './pty-child-process-inspection' import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' +import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' import { ORCA_IMAGE_PROTOCOL_ENV, @@ -204,6 +206,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | } type ManagedPty = { + freebuffStatus?: FreebuffStatusProjection id: string incarnationId: string pty: IPty @@ -853,6 +856,22 @@ export class PtyHandler { if (!result.TERM) { result.TERM = 'xterm-256color' } + // Why: the relay's own process env can carry pane identity (it is itself startable from + // an Orca pane), and unlike the local and daemon builders this one never dropped it. A + // spawn that specified no identity would then inherit someone else's, and every agent's + // hook would report against that pane. Drop it before mirroring, so an alias can only + // ever carry identity this spawn actually asked for. + for (const key of ['ORCA_PANE_KEY', 'ORCA_AGENT_LAUNCH_TOKEN'] as const) { + if (!rendererEnv || !Object.hasOwn(rendererEnv, key)) { + delete result[key] + } + } + // Why here and not only in the local/daemon builders: a remote pane's env is built HERE, + // and the client forwards only the canonical pane-identity names. An agent whose harness + // scrubs those names (DSH drops any env var whose name contains KEY or TOKEN) would find + // nothing to attribute its hooks to, so remote status would silently never appear even + // with the remote hook installed. + applyScrubSafeAgentEnvAliases(result) // Why last, not beside the scrubbers above: the relay runs those BEFORE envToDelete, // so an envToDelete of CONDA_PREFIX would otherwise re-create the broken pair. dropIncoherentCondaActivationEnv(result, process.platform) @@ -954,11 +973,12 @@ export class PtyHandler { this.notifyPoolListener(this.ptyPoolActiveListener, 'pty-pool-active') const emitIngressData = (emission: PtyIngressEmission): void => { const rawLength = emission.rawEndSeq - emission.rawStartSeq - this.appendReplayBuffer(managed, emission.data) + const data = managed.freebuffStatus?.project(emission.data) ?? emission.data + this.appendReplayBuffer(managed, data) this.enqueuePtyOutput( managed.id, - emission.data, - emission.transformed || rawLength !== emission.data.length + data, + emission.transformed || rawLength !== data.length ? { rawLength, seq: emission.rawEndSeq, transformed: true } : {} ) @@ -1069,6 +1089,7 @@ export class PtyHandler { // teardown mid-proof must still deliver the held prompt before exit. managed.recoveryBarrier?.flushPending() managed.recoveryBarrier?.dispose() + managed.freebuffStatus?.dispose() } private notifyExitListener(managed: ManagedPty): void { @@ -1097,6 +1118,7 @@ export class PtyHandler { this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p)) this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p)) this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) + this.dispatcher.onRequest('pty.resetInputModes', (p) => this.resetInputModes(p)) this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) this.dispatcher.onRequest('pty.inspectProcess', (p) => this.inspectProcess(p)) @@ -1994,6 +2016,9 @@ export class PtyHandler { const ownerClientInstanceId = context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) const managed: ManagedPty = { + ...(launchAgent === 'freebuff' + ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } + : {}), id, incarnationId: randomUUID(), pty: term, @@ -2240,6 +2265,7 @@ export class PtyHandler { // npm, where the patch is not applied. So the catch below stays. try { managed.pty.resize(cols, rows) + managed.freebuffStatus?.resize(cols, rows) } catch (err) { // A failed ioctl observed the handle, not the host's process table, so on // its own it is `unverifiable`. Re-probe: a now-absent pid retires the @@ -2661,6 +2687,15 @@ export class PtyHandler { } } + // Why the replay buffer and not the stream: a zero-raw span never crosses the + // credit window, and the client grounds its own view; reattach replays this. + private async resetInputModes(params: Record): Promise { + const managed = this.ptys.get(params.id as string) + if (managed?.recoveryBarrier && !managed.disposed) { + this.appendReplayBuffer(managed, managed.recoveryBarrier.groundInputModes()) + } + } + private async hasChildProcesses(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 64e49519800..05ffc660732 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -724,7 +724,9 @@ body:has([data-slot='dialog-overlay'], [data-slot='sheet-overlay']) [data-sonner mask-image: linear-gradient(to right, transparent, #000 1.5rem, #000 100%); } -.terminal-tab-strip--fade-end:not(.terminal-tab-strip--fade-start) { +/* Why: the status bar's usage cluster reuses the end fade so a clipped cluster never cuts a glyph. */ +.terminal-tab-strip--fade-end:not(.terminal-tab-strip--fade-start), +.status-bar-usage-cluster[data-overflowing='true'] { -webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 1.5rem), transparent); mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 1.5rem), transparent); } diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx index f95815b2552..06e2cb38721 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.clipboard-routes.test.tsx @@ -88,6 +88,7 @@ vi.mock('@xterm/xterm', () => ({ scrollToBottom = vi.fn() selectAll = vi.fn() getSelection = vi.fn(() => this.selectionText) + hasSelection = vi.fn(() => this.selectionText !== '') attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { this.customKeyHandler = handler }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx index 62f5b5b2a1d..65e7026291b 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx @@ -37,6 +37,7 @@ vi.mock('@xterm/xterm', () => ({ scrollToBottom = vi.fn() selectAll = vi.fn() getSelection = vi.fn(() => '') + hasSelection = vi.fn(() => false) attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { this.customKeyHandler = handler }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx index 4a63a289551..d31f7293e76 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx @@ -80,6 +80,7 @@ vi.mock('@xterm/xterm', () => ({ scrollToBottom = vi.fn() selectAll = vi.fn() getSelection = vi.fn(() => this.selectionText) + hasSelection = vi.fn(() => this.selectionText !== '') attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => { this.customKeyHandler = handler }) @@ -213,7 +214,8 @@ describe('AgentTerminalPreview', () => { expect(input).toHaveBeenCalledTimes(1) expect(input).toHaveBeenCalledWith('pty-1', 'k') - act(() => terminal.writeCallbacks.shift()?.()) + // Why drain all: the connection's kitty restore write queues ahead of the live chunk. + act(() => terminal.writeCallbacks.splice(0).forEach((callback) => callback())) expect(ack).toHaveBeenCalledWith('pty-1', 4) }) @@ -271,6 +273,11 @@ describe('AgentTerminalPreview', () => { render() await waitFor(() => expect(imeHarness.forwarders).toHaveLength(1)) await waitFor(() => expect(imeHarness.forwarders[0]!.getKittyKeyboardFlags()).toBe(8)) + // The popout xterm gets the same flags, so its encoder agrees with the mirror. + expect(terminalHarness.instances[0]!.write).toHaveBeenCalledWith( + '\x1b[<99u\x1b[=8u', + expect.any(Function) + ) // Live output keeps advancing the same mirror the forwarder reads. act(() => { @@ -358,6 +365,37 @@ describe('AgentTerminalPreview', () => { expect(writeTerminalClipboardText).not.toHaveBeenCalled() }) + it('hands unselected Cmd+C to a kitty app and copies a selection instead', async () => { + platformState.value = 'darwin' + render() + await waitFor(() => expect(terminalHarness.instances).toHaveLength(1)) + const terminal = terminalHarness.instances[0]! + await waitFor(() => expect(terminal.customKeyHandler).not.toBeNull()) + const cmdC = (type: string): KeyboardEvent => + new KeyboardEvent(type, { key: 'c', code: 'KeyC', metaKey: true, cancelable: true }) + + // A plain shell's unselected Cmd+C sends nothing. + expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false) + expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false) + + act(() => { + emitData?.({ type: 'data', ptyId: 'pty-1', data: '\x1b[>1u', bytes: 5 }) + }) + expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(true) + expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(true) + + // A highlight of blank cells copies no text but is still Orca's selection, as in the pane. + Object.assign(terminal, { hasSelection: () => true }) + expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false) + expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false) + expect(writeTerminalClipboardText).not.toHaveBeenCalled() + + terminal.selectionText = 'selected text' + expect(terminal.customKeyHandler!(cmdC('keydown'))).toBe(false) + expect(terminal.customKeyHandler!(cmdC('keyup'))).toBe(false) + expect(writeTerminalClipboardText).toHaveBeenCalledWith('selected text') + }) + it('selects all terminal text on Cmd+A and blocks xterm handling', async () => { platformState.value = 'darwin' render() diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 59ed54f68d2..875f13a96f4 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -11,7 +11,8 @@ import { replayPreviewConnectionSnapshot } from './preview-terminal-snapshot-rep import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt' import { buildPreviewAppearanceOptions, - buildPreviewTerminalOptions + buildPreviewTerminalOptions, + previewAdvertisesKittyKeyboard } from './preview-terminal-options' import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures' import { installPreviewTerminalCompatibility } from './preview-terminal-compatibility' @@ -116,9 +117,13 @@ export function AgentTerminalPreview({ let disposeKeyHandler: (() => void) | null = null let disposeNativeCopyGutterTrim: (() => void) | null = null let disposeTerminalCompatibility: (() => void) | null = null + // Why one read: the xterm's advertisement and its mirror must never disagree. + const mountTerminalInput = terminalInputRef.current // Why: mirrors the pane's tracker — the policy needs the flags the TUI // negotiated, and this preview parses the same output stream the pane does. - const kittyKeyboardModes = new TerminalKittyKeyboardModeTracker() + const kittyKeyboardModes = new TerminalKittyKeyboardModeTracker({ + kittyKeyboard: previewAdvertisesKittyKeyboard(mountTerminalInput) + }) let refreshInFlight = false let refreshAgain = false let retryTimer: ReturnType | null = null @@ -254,7 +259,7 @@ export function AgentTerminalPreview({ terminal = new Terminal( buildPreviewTerminalOptions({ settings: settingsRef.current, - terminalInput: terminalInputRef.current, + terminalInput: mountTerminalInput, macOptionIsMeta: macOptionAsAltRef.current === 'true', theme: terminalTheme, themeMode: terminalMode, diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts index 1b263144a53..faaeb0048dc 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-key-handler.ts @@ -14,6 +14,7 @@ import { type PreviewShortcutContext } from './preview-terminal-shortcuts' import { readTerminalClipboardSelection } from '@/components/terminal-pane/terminal-clipboard-selection-text' +import { isAppOwnedCopyChord } from '@/components/terminal-pane/xterm-bypass-policy' /** * Installs the preview terminal's ONE custom key handler (xterm allows a single @@ -110,6 +111,15 @@ export function installPreviewTerminalKeyHandler(args: { nativeOnlyShortcutTracker.prepareKeyDown(event) const keybindings = useAppStore.getState().keybindings if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { + if ( + isAppOwnedCopyChord(event, { + isMac: platform === 'darwin', + hasSelection: terminal.hasSelection(), + kittyKeyboardFlags: args.getShortcutContext().getKittyKeyboardFlags() + }) + ) { + return true + } const selection = readTerminalClipboardSelection(terminal) if ( !selection && diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts index c27f085029d..3bf324e385d 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts @@ -45,6 +45,13 @@ export function buildPreviewAppearanceOptions( } } +// Why: local ConPTY CLIs read the advertisement but can't decode CSI-u (#2434); mirror the pane's withhold. +export function previewAdvertisesKittyKeyboard( + terminalInput: DashboardCardTerminalInput | null +): boolean { + return !terminalInput || terminalInput.kittyKeyboardAdvertised +} + /** * Full option set for the preview's xterm: the same defaults, user appearance, * and host compatibility flags a pane resolves, so the agent's TUI negotiates @@ -65,10 +72,9 @@ export function buildPreviewTerminalOptions(args: { ...(args.terminalInput?.localWindowsConpty ? buildLocalConptyTerminalOptions(args.terminalInput.osRelease) : {}), - // Why: local ConPTY CLIs read the advertisement but can't decode CSI-u (#2434); mirror the pane's withhold. - ...(args.terminalInput && !args.terminalInput.kittyKeyboardAdvertised - ? { vtExtensions: { kittyKeyboard: false } } - : {}) + ...(previewAdvertisesKittyKeyboard(args.terminalInput) + ? {} + : { vtExtensions: { kittyKeyboard: false } }) } return { ...buildDefaultTerminalOptions(), diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts index 3e288626259..f3b3ed4f800 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts @@ -110,7 +110,7 @@ describe('replayPreviewConnectionSnapshot', () => { expect(modes.snapshotFlags).toBe(0) }) - it('writes scrollback, frame, escape tail, then replay, in that order', () => { + it('writes scrollback, frame, kitty restore, escape tail, then replay, in that order', () => { const { written } = apply( { scrollbackAnsi: 'history', @@ -119,6 +119,6 @@ describe('replayPreviewConnectionSnapshot', () => { }, [{ data: 'tail', mode: 'live' }] ) - expect(written).toEqual(['history', 'frame', '\x1b[', 'tail']) + expect(written).toEqual(['history', 'frame', '\x1b[<99u', '\x1b[', 'tail']) }) }) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts index 43d4333cc17..9e684353eb7 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.ts @@ -1,5 +1,6 @@ import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' +import { buildKittyKeyboardRestore } from '../../../../shared/terminal-mode-reset-profiles' import type { TerminalPreviewReplayChunk, TerminalPreviewSnapshot @@ -7,7 +8,7 @@ import type { /** * Apply snapshot + buffered replay, restoring proven kitty flags after the - * snapshot scan (snapshot ANSI omits kitty pushes). Synchronous so no browser + * snapshot bytes (snapshot ANSI omits kitty pushes). Synchronous so no browser * event observes the temporary reset. */ export function replayPreviewConnectionSnapshot(args: { @@ -29,12 +30,11 @@ export function replayPreviewConnectionSnapshot(args: { if (snapshot.data) { args.write(snapshot.data, false) } + // Why as bytes: the popout xterm must parse the same restore its mirror scans. + args.write(buildKittyKeyboardRestore(provenFlags), false) if (snapshot.pendingEscapeTailAnsi) { args.write(snapshot.pendingEscapeTailAnsi, false) } - if (provenFlags !== undefined) { - kittyKeyboardModes.restoreSnapshotFlags(provenFlags) - } for (const chunk of args.replay) { args.write(chunk.data, chunk.mode === 'live') } diff --git a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts index 308e7b878e8..7dfc8938373 100644 --- a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts +++ b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts @@ -25,8 +25,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ })) vi.mock('sonner', () => ({ toast: vi.fn() })) -// After a restart action leaves a chat failed, the user's reply in that chat must retire the status -// bar entry without the user reopening anything, and nothing may run while nothing failed. +// When the user sends in an offered or failed chat, its status bar entry must retire without the +// user reopening anything, and nothing may run while nothing is offered or failed. const failure = { sessionId: 'a', @@ -91,7 +91,26 @@ afterEach(() => { vi.useRealTimers() }) -it('opens no status stream while nothing failed', async () => { +// The host withdraws a pending offer once the chat moves on; the status bar must not keep counting it. +it('re-reads once when the user sends in an offered chat, and the count drops', async () => { + const { failedAt: _failedAt, outcome: _outcome, reason: _reason, ...candidate } = failure + mocks.rpc + .mockResolvedValueOnce({ sessions: [candidate], failed: [] }) + .mockResolvedValue({ sessions: [], failed: [] }) + await refreshNativeChatRestartOffer() + await vi.advanceTimersByTimeAsync(0) + expect(getNativeChatRestartOffer().candidates).toHaveLength(1) + expect(mocks.subscribeStatus).toHaveBeenCalledOnce() + + hostEmit()({ type: 'status', session: summary('working', 'Something else', Date.now() + 10_000) }) + await vi.advanceTimersByTimeAsync(500) + + expect(offerReads()).toBe(2) + expect(getNativeChatRestartOffer().candidates).toEqual([]) + expect(mocks.unsubscribe).toHaveBeenCalledOnce() +}) + +it('opens no status stream while nothing is offered or failed', async () => { mocks.rpc.mockResolvedValue({ sessions: [], failed: [] }) await refreshNativeChatRestartOffer() await vi.advanceTimersByTimeAsync(0) 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 a3012495a3c..78c5fa207b6 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 @@ -58,42 +58,47 @@ const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const * actually moves the offer. */ function publish(next: NativeChatRestartOffer): void { offer = next - syncFailedChatWatch() + syncOfferedChatWatch() for (const listener of listeners) { listener() } } /** - * Re-reads the host once a failed chat shows new activity, so a reply the user sent there retires - * its entry here too. The host stays the judge; this only asks again. + * 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 + * asks again. * - * Held only while something failed. Keyed on status and prompt rather than every summary, so an - * agent streaming in a failed chat costs one re-read, not one per tool call. + * Held only while something is offered or failed. Keyed on status and prompt rather than every + * summary, so an agent streaming in such a chat costs one re-read, not one per tool call. */ -const FAILED_CHAT_REFRESH_DELAY_MS = 500 -let failedChatWatch: { +const OFFERED_CHAT_REFRESH_DELAY_MS = 500 +let offeredChatWatch: { feed: StructuredAgentSessionStatusFeedOwner seen: Map release: () => void } | null = null -let failedChatRefresh: ReturnType | null = null +let offeredChatRefresh: ReturnType | null = null -function failedChatActivityKey(summary: AgentSessionStatusSummary): string { +function offeredChatIds(): Set { + return new Set([...offer.candidates, ...offer.failed].map((entry) => entry.sessionId)) +} + +function offeredChatActivityKey(summary: AgentSessionStatusSummary): string { return `${summary.status ?? ''}\u0000${summary.latestPrompt}` } -function syncFailedChatWatch(): void { - const failedIds = new Set(offer.failed.map((failure) => failure.sessionId)) - if (failedIds.size === 0) { - releaseFailedChatWatch() +function syncOfferedChatWatch(): void { + const offeredIds = offeredChatIds() + if (offeredIds.size === 0) { + releaseOfferedChatWatch() return } - if (!failedChatWatch) { + if (!offeredChatWatch) { const feed = getStructuredAgentSessionStatusFeed(LOCAL) - const unsubscribe = feed.subscribe(noticeFailedChatActivity) + const unsubscribe = feed.subscribe(noticeOfferedChatActivity) const deactivate = feed.activate() - failedChatWatch = { + offeredChatWatch = { feed, seen: new Map(), release: () => { @@ -102,60 +107,60 @@ function syncFailedChatWatch(): void { } } } - const { feed, seen } = failedChatWatch + const { feed, seen } = offeredChatWatch for (const sessionId of seen.keys()) { - if (!failedIds.has(sessionId)) { + if (!offeredIds.has(sessionId)) { seen.delete(sessionId) } } // What the feed already holds is what this listing answered. - for (const sessionId of failedIds) { + for (const sessionId of offeredIds) { const summary = feed.getSnapshot().get(sessionId) if (summary && !seen.has(sessionId)) { - seen.set(sessionId, failedChatActivityKey(summary)) + seen.set(sessionId, offeredChatActivityKey(summary)) } } } -function noticeFailedChatActivity(): void { - if (!failedChatWatch) { +function noticeOfferedChatActivity(): void { + if (!offeredChatWatch) { return } - const { feed, seen } = failedChatWatch + const { feed, seen } = offeredChatWatch const snapshot = feed.getSnapshot() let changed = false - for (const failure of offer.failed) { - const summary = snapshot.get(failure.sessionId) + for (const sessionId of offeredChatIds()) { + const summary = snapshot.get(sessionId) if (!summary) { continue } - const key = failedChatActivityKey(summary) - const previous = seen.get(failure.sessionId) + const key = offeredChatActivityKey(summary) + const previous = seen.get(sessionId) if (previous !== key) { - seen.set(failure.sessionId, key) + seen.set(sessionId, key) // A first sighting is news only if newer than the list; a change to a known chat always is, // since the host may have answered the list just before the change was delivered here. changed ||= previous !== undefined || summary.updatedAt > offer.listedAt } } - if (changed && failedChatRefresh === null) { - failedChatRefresh = setTimeout(() => { - failedChatRefresh = null + if (changed && offeredChatRefresh === null) { + offeredChatRefresh = setTimeout(() => { + offeredChatRefresh = null if (actionsBegun === actionsSettled) { const issued = actionsBegun void readNativeChatRestartOffer(() => actionsBegun === issued) } - }, FAILED_CHAT_REFRESH_DELAY_MS) + }, OFFERED_CHAT_REFRESH_DELAY_MS) } } -function releaseFailedChatWatch(): void { - if (failedChatRefresh !== null) { - clearTimeout(failedChatRefresh) - failedChatRefresh = null +function releaseOfferedChatWatch(): void { + if (offeredChatRefresh !== null) { + clearTimeout(offeredChatRefresh) + offeredChatRefresh = null } - failedChatWatch?.release() - failedChatWatch = null + offeredChatWatch?.release() + offeredChatWatch = null } export function getNativeChatRestartOffer(): NativeChatRestartOffer { @@ -358,7 +363,7 @@ export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOf /** @internal - tests need a clean module between cases. */ export function _resetNativeChatRestartOffer(): void { - releaseFailedChatWatch() + releaseOfferedChatWatch() offer = EMPTY actionsBegun = 0 actionsSettled = 0 diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.test.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.test.tsx deleted file mode 100644 index a897588f7c5..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment happy-dom -import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { - createStructuredAgentSessionOutboxEntry, - type StructuredAgentSessionOutboxEntry -} from '../../../../shared/structured-agent-session-outbox' -import { NativeChatDeliveryRetry } from './NativeChatDeliveryRetry' - -const blocked = createStructuredAgentSessionOutboxEntry({ - clientMessageId: 'client-1', - sessionId: 'session-1', - text: 'hello', - attachments: [], - queuedAt: 1 -}) - -function renderRow(entry: StructuredAgentSessionOutboxEntry): void { - render( - {}} - /> - ) -} - -afterEach(cleanup) - -describe("the Retry row's reason", () => { - it('chooses its words from the saved failure when it is shown', () => { - renderRow({ - ...blocked, - lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } - }) - - expect( - screen.getByText("The agent couldn't restart. Your message was not sent.") - ).toBeInTheDocument() - }) - - it("shows a provider's rejection in the provider's words", () => { - renderRow({ - ...blocked, - lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } - }) - - expect(screen.getByText('Claude messages support at most 20 images')).toBeInTheDocument() - }) - - it('says only that the message was not sent when nothing more is known', () => { - renderRow(blocked) - - expect(screen.getByText('Message was not sent.')).toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx b/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx deleted file mode 100644 index e37016d646d..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatDeliveryRetry.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { RotateCcw } from 'lucide-react' -import { - admitStructuredAgentSessionOutboxEntry, - type StructuredAgentSessionOutboxEntry -} from '../../../../shared/structured-agent-session-outbox' -import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' -import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { Button } from '@/components/ui/button' -import { translate } from '@/i18n/i18n' - -export function NativeChatDeliveryRetry({ - outbox, - blockedClientMessageId, - retry -}: { - outbox: readonly StructuredAgentSessionOutboxEntry[] - blockedClientMessageId: string | null - retry: (clientMessageId: string) => void -}): React.JSX.Element | null { - // Why: read through the drain's own rule, so Retry can never name an entry other than the one - // the queue actually stopped on -- which is no longer always the head. A rejected entry holds - // nothing up, so it is offered only when the queue itself is not stopped. - const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) - const retryable = - admission.state === 'blocked' - ? admission.entry - : (outbox.find((entry) => entry.state === 'rejected') ?? null) - if (!retryable) { - return null - } - return ( -

- - {retryable.state === 'unconfirmed' - ? translate( - 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0', - 'Message delivery is unconfirmed.' - ) - : retryable.lastFailure - ? agentSessionWriteNoticeText( - structuredAgentSessionAttemptFailureParts(retryable.lastFailure) - ) - : translate( - 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', - 'Message was not sent.' - )} - - -
- ) -} diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx new file mode 100644 index 00000000000..dccfaba728c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { NativeChatEmptyState } from './NativeChatEmptyState' + +afterEach(cleanup) + +it('keeps pointing the terminal-backed chat back to its terminal when a read fails', () => { + render() + expect( + screen.getByText( + 'The transcript could not be read. Toggle back to the terminal to keep working.' + ) + ).toBeInTheDocument() +}) + +it('tells the structured chat its read keeps retrying', () => { + render() + expect( + screen.getByText('The transcript could not be read. Orca keeps trying to load it.') + ).toBeInTheDocument() +}) + +it('shows the host message in place of the terminal-backed default', () => { + render() + expect(screen.getByText('disk full')).toBeInTheDocument() + expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull() +}) + +// The structured chat passes words only from the notice table, never the host's. +it("says the structured chat's own words for the failure once, as the title, above its retrying line", () => { + render( + + ) + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toHaveClass( + 'font-medium' + ) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect( + screen.getByText('The transcript could not be read. Orca keeps trying to load it.') + ).toBeInTheDocument() +}) + +it('says a failure no retry gets past in its one line, with nothing of trying again', () => { + render() + expect(screen.getByText('Unable to load this chat.')).toHaveClass('font-medium') + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying|Toggle back/)).toBeNull() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx index f7c20ef51f4..ec84f554cd7 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx @@ -7,13 +7,20 @@ import type { NativeChatSession } from '../../../../shared/native-chat-types' export function NativeChatEmptyState({ kind, message, - agent + headline, + agent, + retrying = false }: { kind: 'loading' | 'empty' | 'error' | 'not-agent' message?: string + /** The chat's own sentence for the failure, said once: it takes the generic title's place. */ + headline?: string agent?: NativeChatSession['agent'] + /** The read retries on its own (structured chat), so the error says so instead of pointing + * back to the terminal. */ + retrying?: boolean }): React.JSX.Element { - const copy = emptyStateCopy(kind, message, agent) + const copy = emptyStateCopy(kind, { message, headline }, agent, retrying) return (
void }): React.JSX.Element | null { if (lifecycle !== 'failed' && lifecycle !== 'visibility-unknown') { @@ -25,11 +32,17 @@ export function NativeChatLaunchRetry({ 'auto.components.native.chat.NativeChatLaunchRetry.unknown', 'Chat connection could not be confirmed.' ) + const cause = + lifecycle === 'failed' && failure + ? agentSessionWriteNoticeText( + agentSessionRefusalCauseParts(failure, agentLabel ? { agentName: agentLabel } : {}) + ) + : '' return (
{message} - {lifecycle === 'failed' && failureReason ? ` ${failureReason}` : null} + {cause ? ` ${cause}` : null}
) : null} - {deliveryFailed ? ( -
- {translate( - 'components.native-chat.launchPromptNotDelivered', - 'Not delivered — check the terminal' - )} + {deliveryNotice ? ( +
+ {deliveryNotice.text} + {deliveryNotice.onRetry ? ( + + ) : null}
) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index e711d62245a..fb432e1e5b4 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -6,6 +6,7 @@ import { useNativeChatRetainedSession } from './use-native-chat-retained-session import { isNativeChatTranscriptUnsettled } from './native-chat-live-session-contract' import { selectNativeChatViewState } from './native-chat-view-state' import { NativeChatMessageList } from './NativeChatMessageList' +import { useNativeChatLaunchPromptDeliveryNotice } from './use-native-chat-launch-prompt-delivery-notice' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' import { useNativeChatFontScale } from './use-native-chat-font-scale' import { useNativeChatCanSend } from './use-native-chat-can-send' @@ -246,13 +247,10 @@ export function NativeChatResolvedView({ ? sessionWithLaunchPrompt : { ...sessionWithLaunchPrompt, messages } }, [sessionWithLaunchPrompt, commandMarkers]) - const failedLaunchPromptMessageIds = useMemo(() => { - const id = paneLaunchPrompt?.failed ? launchPromptMessage?.id : null - if (!id || !sessionAfterCommandBoundaries.messages.some((message) => message.id === id)) { - return undefined - } - return new Set([id]) - }, [paneLaunchPrompt?.failed, launchPromptMessage?.id, sessionAfterCommandBoundaries.messages]) + const launchPromptDeliveryNotices = useNativeChatLaunchPromptDeliveryNotice( + paneLaunchPrompt?.failed ? launchPromptMessage?.id : null, + sessionAfterCommandBoundaries.messages + ) // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. @@ -408,7 +406,7 @@ export function NativeChatResolvedView({ showTurnStatus={false} onLinkClick={onLinkClick} allowFileUriLinks={fileLinkContext !== null} - failedDeliveryMessageIds={failedLaunchPromptMessageIds} + deliveryNotices={launchPromptDeliveryNotices} /> )}
diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index cb777f910f2..f86fc972c22 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -4,6 +4,10 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import type * as ReactModule from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' + +const toastError = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: { error: toastError } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => { @@ -441,6 +445,55 @@ describe('NativeChatSessionOptionPickers', () => { await waitFor(() => expect(invokeAction).toHaveBeenCalledWith('model')) }) + it.each([ + [ + "the table's words for a host's refusal, not its message", + new RuntimeRpcCallError({ + id: 'request-1', + ok: false, + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + }, + _meta: { runtimeId: 'runtime-1' } + }), + "Unable to load this chat. The setting wasn't changed." + ], + [ + "a local surface's own sentence", + new Error('The terminal did not accept the command.'), + 'The terminal did not accept the command.' + ] + ])('describes a failed option change with %s', async (_label, error, description) => { + toastError.mockClear() + const invokeAction = vi.fn().mockRejectedValue(error) + render( + + ) + screen.getByRole('button', { name: 'Choose in agent picker…' }).click() + await waitFor(() => + expect(toastError).toHaveBeenCalledExactlyOnceWith('Could not update option', { + description + }) + ) + }) + it('uses a Toggle action for unknown flip-only options via invokeAction', async () => { const invokeAction = vi.fn().mockResolvedValue({ snapshot: [] }) const setOption = vi.fn().mockResolvedValue({ snapshot: [] }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 507103d485f..0dacff0289e 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -32,6 +32,9 @@ import { nativeChatSessionOptionLabel } from './native-chat-session-option-labels' import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' +import { agentSessionThrownFailure } from '../../../../shared/agent-session-write-failure' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { agentSessionWriteFailureText } from './agent-session-write-notice-text' export type NativeChatSessionOptionPickersProps = { surface: SessionOptionsSurface | null @@ -204,6 +207,15 @@ function DescriptorMenuRows(props: { ) } +/** A host's error is words for its log, so it gets the table's; a local surface's error is + * already written for the person. */ +function optionUpdateFailureDescription(error: unknown): string { + if (error instanceof RuntimeRpcCallError) { + return agentSessionWriteFailureText(agentSessionThrownFailure(error, error.code), 'option') + } + return error instanceof Error ? error.message : String(error) +} + function runSurfaceCall( pendingKey: string, setPendingId: (id: string | null) => void, @@ -214,7 +226,7 @@ function runSurfaceCall( .catch((error) => { toast.error( translate('components.native-chat.composer.optionUpdateFailed', 'Could not update option'), - { description: error instanceof Error ? error.message : String(error) } + { description: optionUpdateFailureDescription(error) } ) }) .finally(() => setPendingId(null)) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx index 9513d0cfeb6..644c1c65750 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx @@ -26,6 +26,14 @@ vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { readOutbox } from './structured-agent-session-outbox-storage' +const NOT_SIGNED_IN = { + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } +} as const +// Retry beside it is the resend, so the words keep only the step before it. +const NOT_SIGNED_IN_TEXT = 'Codex is not signed in for the selected account. Sign in first.' + function sessionView(): React.JSX.Element { return ( { expect(mocks.retryLaunch).toHaveBeenCalledWith('wt-1', 'session-1') }) - it('shows why a failed launch failed beside Retry', () => { + it('words why a failed launch failed beside Retry from the refusal, never its code', () => { mocks.launchLifecycle = 'failed' - mocks.launchFailureReason = 'claude is not signed in' + mocks.launchFailure = NOT_SIGNED_IN render(sessionView()) - expect(screen.getByText('Chat could not be started. claude is not signed in')).toBeTruthy() + expect(screen.getByText(`Chat could not be started. ${NOT_SIGNED_IN_TEXT}`)).toBeTruthy() + expect(screen.queryByText(/agent_session_/)).toBeNull() + }) + + it("keeps a step the Retry doesn't take, and drops one it does", () => { + mocks.launchLifecycle = 'failed' + mocks.launchFailure = { + kind: 'refused', + code: 'agent_session_conflict', + details: { reason: 'claimConflicted' } + } + const { rerender } = render(sessionView()) + expect( + screen.getByText( + 'Chat could not be started. This chat is still open in a terminal agent. Quit that agent to continue the chat here.' + ) + ).toBeTruthy() + + mocks.launchFailure = { + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + rerender(sessionView()) + expect( + screen.getByText( + "Chat could not be started. Orca couldn't open this chat's history right now." + ) + ).toBeTruthy() + }) + + it('says only that the chat could not start when the refusal names no reason', () => { + mocks.launchLifecycle = 'failed' + mocks.launchFailure = { kind: 'refused', code: 'agent_session_operation_invalid' } + render(sessionView()) + + expect(screen.getByText('Chat could not be started.')).toBeTruthy() + expect(screen.queryByText(/agent_session_/)).toBeNull() }) it('keeps a stale reason off a launch that is no longer failed', () => { mocks.launchLifecycle = 'visibility-unknown' - mocks.launchFailureReason = 'claude is not signed in' + mocks.launchFailure = NOT_SIGNED_IN render(sessionView()) expect(screen.getByText('Chat connection could not be confirmed.')).toBeTruthy() @@ -177,10 +222,10 @@ describe('NativeChatStructuredSession launch lifecycle', () => { const { rerender } = render(sessionView()) expect(composerSend()('still there?', [])).toBe(true) - mocks.launchFailureReason = 'claude is not signed in' + mocks.launchFailure = NOT_SIGNED_IN rerender(sessionView()) - expect(screen.getByText('Chat could not be started. claude is not signed in')).toBeTruthy() + expect(screen.getByText(`Chat could not be started. ${NOT_SIGNED_IN_TEXT}`)).toBeTruthy() expect(mocks.call).not.toHaveBeenCalled() expect(readOutbox('session-1')).toEqual([ expect.objectContaining({ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx new file mode 100644 index 00000000000..89ef518feb3 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, 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('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +afterEach(() => { + cleanup() + resetStructuredSessionMocks() +}) + +function renderPane(): void { + render( + + ) +} + +function journalRefusal(reason: 'journalCorrupt' | 'journalUnavailable') { + return { code: 'agent_session_journal_unreadable', details: { reason } } as const +} + +// The host's message and code never reach the pane; it words the refusal, and says it once. +it('says a failed read with no refusal keeps retrying, and adds nothing of the host', () => { + mocks.status = 'error' + mocks.messages = [] + + renderPane() + + expect(screen.getByText('Could not load conversation')).toBeTruthy() + expect( + screen.getByText('The transcript could not be read. Orca keeps trying to load it.') + ).toBeTruthy() + expect(screen.queryByText(/history couldn't be loaded/)).toBeNull() + expect(screen.queryByText(/Toggle back to the terminal/)).toBeNull() +}) + +it('says a damaged history cannot load in one line, without claiming Orca keeps trying', () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalCorrupt') + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText('Unable to load this chat.')).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(screen.queryByText(/agent_session_/)).toBeNull() +}) + +it("names a history that couldn't open right now once, and that the pane keeps trying", () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalUnavailable') + mocks.messages = [] + + renderPane() + + expect(screen.getAllByText("Orca couldn't open this chat's history right now.")).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect( + screen.getByText('The transcript could not be read. Orca keeps trying to load it.') + ).toBeTruthy() + expect(screen.queryByText(/Try again/)).toBeNull() +}) + +it('says only that it is reconnecting, not as an error, when a failure names nothing', () => { + mocks.status = 'error' + + renderPane() + + expect(screen.getByTestId('message-list')).toBeTruthy() + const reconnecting = screen.getByText('Reconnecting to this chat…') + expect(reconnecting.className).not.toContain('text-destructive') + expect(screen.queryByText(/history couldn't be loaded/)).toBeNull() +}) + +it('words a failed reconnect beside a transcript it keeps', () => { + mocks.status = 'error' + mocks.readRefusal = journalRefusal('journalUnavailable') + + renderPane() + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx new file mode 100644 index 00000000000..6f529c1d3c9 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' + +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' + +afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() +}) + +const SESSION_ID = 'start-failure-session' +const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } +const START_FAILED_REASON = + 'Claude stopped before it finished starting. Send your message to try again.' + +function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')), + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } + } +} + +function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) { + return { + outbox: { + clientMessageId, + sessionId: SESSION_ID, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + previewUris: [], + state: 'rejected', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null, + lastFailure: { kind: 'rejected', reason, rejection: { kind: rejection.kind } } + }, + submission: { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'rejected', + providerItemId: null, + reason, + rejection, + submittedAt: 1, + resolvedAt: 1 + } + } +} + +function renderPane(messages: ReturnType[]): void { + mocks.mode = 'outbox' + mocks.submissions = messages.map((message) => message.submission) + localStorage.setItem( + `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, + JSON.stringify(messages.map((message) => message.outbox)) + ) + render( + + ) +} + +async function notice(clientMessageId: string): Promise { + return waitFor(() => { + const row = document.querySelector( + `[data-message-id="${agentJournalSubmissionKey(clientMessageId)}"]` + ) + if (!row) { + throw new Error(`no notice on ${clientMessageId}`) + } + return row + }) +} + +// The start's own row says why, so its rejected messages say only that they were not sent. +it("says only 'not sent', with its Retry, on each message the failed start's row explains", async () => { + mocks.journalItems = [startFailureRow(START_FAILED)] + + renderPane([ + rejected('first', START_FAILED_REASON, START_FAILED), + rejected('second', START_FAILED_REASON, START_FAILED) + ]) + + for (const id of ['first', 'second']) { + const row = await notice(id) + expect(within(row).getByText('Your message was not sent.')).toBeTruthy() + expect(within(row).getByRole('button', { name: 'Retry' })).toBeTruthy() + } + expect(screen.queryByText(/stopped before it finished starting/)).toBeNull() +}) + +it('keeps the full notice on a message rejected for a reason no start-failure row states', async () => { + mocks.journalItems = [startFailureRow(START_FAILED)] + const providerRejected: AgentSessionFailureFact = { + kind: 'providerRejected', + detail: { text: 'Image type .bmp', audience: 'person' } + } + + renderPane([ + rejected('stated', START_FAILED_REASON, START_FAILED), + rejected( + 'other', + 'The provider did not accept this message: Image type .bmp.', + providerRejected + ) + ]) + + expect(within(await notice('stated')).getByText('Your message was not sent.')).toBeTruthy() + expect( + within(await notice('other')).getByText( + 'The provider did not accept this message: Image type .bmp.' + ) + ).toBeTruthy() +}) + +it("keeps the start failure's own words when its row is not loaded", async () => { + renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) + + expect( + within(await notice('first')).getByText('Claude stopped before it finished starting.') + ).toBeTruthy() +}) 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 86963cb542b..5689cf9689f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -2,7 +2,10 @@ import { forwardRef, useImperativeHandle, useRef } from 'react' import { vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' 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' import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' import type { NativeChatLaunchSeed } from './native-chat-composer-types' import type { NativeChatOlderPageResult } from './native-chat-pagination' @@ -19,6 +22,32 @@ function nullable(): T | null { return null } +function absent(): T | undefined { + return undefined +} + +/** Stands in for the transcript: renders only each message's delivery notice and its Retry. */ +export function DeliveryNoticesMock({ + notices +}: { + notices?: ReadonlyMap +}): React.JSX.Element { + return ( +
+ {[...(notices ?? [])].map(([id, notice]) => ( +
+ {notice.text} + {notice.onRetry ? ( + + ) : null} +
+ ))} +
+ ) +} + type StructuredSessionMessageListProps = { allowFileUriLinks?: boolean isVisible?: boolean @@ -28,6 +57,7 @@ type StructuredSessionMessageListProps = { isWorking?: boolean runtimeContext?: unknown session?: { hasMore: boolean; loadingEarlier: boolean; loadEarlier: () => Promise } + deliveryNotices?: ReadonlyMap } const initialMessageListProps: StructuredSessionMessageListProps | null = null @@ -43,12 +73,13 @@ export function createStructuredSessionMocks() { call: vi.fn<(...args: never[]) => unknown>(), fileLinkClick: vi.fn<(...args: never[]) => unknown>(), launchLifecycle: nullable(), - launchFailureReason: nullable(), + launchFailure: nullable(), launchResumes: false, retryLaunch: vi.fn<(...args: never[]) => unknown>(), controllerProps: nullable<{ transportEnabled?: boolean }>(), mode: 'static' as 'static' | 'outbox', status: 'ready' as 'idle' | 'loading' | 'ready' | 'error', + readRefusal: absent(), messages: null as null | unknown[], messageListProps: initialMessageListProps, composerProps: null as null | { @@ -59,6 +90,7 @@ export function createStructuredSessionMocks() { 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>(), handlePasteEvent: vi.fn<(...args: never[]) => unknown>(), @@ -89,6 +121,8 @@ export function createStructuredSessionMocks() { useStructuredAgentSession: async () => { const { useStructuredAgentSessionOutbox } = await import('./use-structured-agent-session-outbox') + const { projectStructuredAgentSessionMessages } = + await import('../../../../shared/structured-agent-session-message-projection') return { useStructuredAgentSession: (props: { sessionId: string @@ -103,11 +137,11 @@ export function createStructuredSessionMocks() { submissions: mocks.submissions as never }) return { - journalItems: [], + journalItems: mocks.journalItems, messages: mocks.messages ?? (mocks.mode === 'outbox' - ? [] + ? projectStructuredAgentSessionMessages([], outbox.outbox, []) : [ { id: 'message-1', @@ -124,12 +158,14 @@ export function createStructuredSessionMocks() { ]), status: mocks.status, error: outbox.error, + readRefusal: mocks.readRefusal, hasOlder: mocks.hasOlder, loadingOlder: mocks.loadingOlder, olderHistoryGeneration: mocks.olderHistoryGeneration, loadOlder: mocks.loadOlder, prompts: mocks.promptItems, outbox: outbox.outbox, + submissions: mocks.submissions, blockedClientMessageId: outbox.blockedClientMessageId, send: outbox.send, retry: outbox.retry, @@ -181,7 +217,7 @@ export function createStructuredSessionMocks() { getStructuredAgentSessionLaunchResumes: () => mocks.launchResumes, useStructuredAgentSessionLaunchSelection: () => null, useStructuredAgentSessionLaunchLifecycle: () => mocks.launchLifecycle, - useStructuredAgentSessionLaunchFailureReason: () => mocks.launchFailureReason + useStructuredAgentSessionLaunchFailure: () => mocks.launchFailure }), useNativeChatFontScale: () => ({ useNativeChatFontScale: () => ({ scale: 1 }) @@ -199,7 +235,7 @@ export function createStructuredSessionMocks() { nativeChatMessageList: () => ({ NativeChatMessageList: (props: typeof mocks.messageListProps) => { mocks.messageListProps = props - return
+ return } }), nativeChatComposer: () => ({ @@ -237,18 +273,20 @@ export function createStructuredSessionMocks() { const resetStructuredSessionMocks = (): void => { mocks.call.mockReset() mocks.launchLifecycle = null - mocks.launchFailureReason = null + mocks.launchFailure = null mocks.launchResumes = false mocks.retryLaunch.mockReset() mocks.controllerProps = null mocks.mode = 'static' mocks.status = 'ready' + mocks.readRefusal = undefined mocks.messages = null mocks.messageListProps = null mocks.composerProps = null mocks.approvalCardProps = null mocks.questionCardProps = null mocks.promptItems = [] + mocks.journalItems = [] mocks.respond.mockReset() mocks.cancel.mockReset() mocks.handlePasteEvent.mockReset() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index d4fbbbf62ba..6daa3d3f62c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' @@ -22,10 +22,15 @@ import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSession import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' -import { NativeChatDeliveryRetry } from './NativeChatDeliveryRetry' import { useStructuredAgentSessionHostExecutionPhase } from './StructuredAgentSessionStatusBridge' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' +import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' + +const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] export function NativeChatStructuredSession( props: Omit @@ -100,7 +105,46 @@ export function NativeChatStructuredSession( }), [controller, props.agent, props.sessionId] ) - const viewState = selectNativeChatViewState(session) + // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. + const retryRef = useRef(controller.retry) + useEffect(() => { + retryRef.current = controller.retry + }) + const retryDelivery = useCallback((clientMessageId: string) => { + retryRef.current(clientMessageId) + }, []) + const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') + // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. + const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') + const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS + const startFailures = useStructuredAgentSessionStartFailureFacts( + controller.journalItems, + hasRejected + ) + const deliveryNotices = useMemo( + () => + structuredAgentSessionDeliveryNotices( + controller.outbox, + controller.blockedClientMessageId, + agentLabel, + retryDelivery, + rejectionRows, + startFailures + ), + [ + controller.outbox, + controller.blockedClientMessageId, + agentLabel, + retryDelivery, + rejectionRows, + startFailures + ] + ) + const viewState = selectNativeChatViewState(session, { readRetries: true }) + const readFailure = + controller.status === 'error' + ? structuredAgentSessionReadFailureNotice(controller.readRefusal) + : null const fontScale = useNativeChatFontScale(viewState.kind === 'ready') const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( @@ -217,7 +261,11 @@ export function NativeChatStructuredSession( {viewState.kind === 'loading' ? ( ) : viewState.kind === 'error' ? ( - + ) : viewState.kind === 'empty' ? ( ) : ( @@ -237,24 +285,26 @@ export function NativeChatStructuredSession( onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} runtimeContext={imageRuntimeContext} + deliveryNotices={deliveryNotices} /> )}
- ({ call: vi.fn(), @@ -45,6 +46,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('./use-structured-agent-session', async () => { const { useStructuredAgentSessionOutbox } = await import('./use-structured-agent-session-outbox') + const { projectStructuredAgentSessionMessages } = + await import('../../../../shared/structured-agent-session-message-projection') return { useStructuredAgentSession: (props: { sessionId: string @@ -60,7 +63,7 @@ vi.mock('./use-structured-agent-session', async () => { journalItems: [], messages: mocks.mode === 'outbox' - ? [] + ? projectStructuredAgentSessionMessages([], outbox.outbox, []) : [ { id: 'message-1', @@ -77,6 +80,7 @@ vi.mock('./use-structured-agent-session', async () => { loadOlder: vi.fn(), prompts: mocks.promptItems, outbox: outbox.outbox, + submissions: mocks.submissions, blockedClientMessageId: outbox.blockedClientMessageId, send: outbox.send, retry: outbox.retry, @@ -131,12 +135,19 @@ vi.mock('./use-native-chat-file-link-click', () => ({ useNativeChatFileLinkClick: (context: unknown) => (context ? mocks.fileLinkClick : undefined) })) -vi.mock('./NativeChatMessageList', () => ({ - NativeChatMessageList: (props: typeof mocks.messageListProps) => { - mocks.messageListProps = props - return
+vi.mock('./NativeChatMessageList', async () => { + const { DeliveryNoticesMock } = await import('./NativeChatStructuredSession.test-harness') + return { + NativeChatMessageList: ( + props: NonNullable & { + deliveryNotices?: ReadonlyMap + } + ) => { + mocks.messageListProps = props + return + } } -})) +}) vi.mock('./NativeChatComposer', () => ({ NativeChatComposer: forwardRef((props: typeof mocks.composerProps, ref) => { @@ -280,6 +291,128 @@ describe('NativeChatStructuredSession delivery', () => { expect(request.envelope.clientOperationId).toBe('op-head') }) + it('offers a rejected message no Retry while the queue is stopped, and gives it back once it moves', async () => { + mocks.mode = 'outbox' + mocks.submissions = [] + mocks.call.mockResolvedValue({ + ok: true, + value: { submission: { clientMessageId: 'op-head', dispatchState: 'accepted' } } + }) + seedOutbox('session-held-rejected', [ + seededEntry('session-held-rejected', 'op-head', 'first', 'unconfirmed'), + { + ...seededEntry('session-held-rejected', 'op-rejected', 'second', 'queued'), + state: 'rejected', + lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } + } + ]) + + render( + + ) + + await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) + expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy() + // One Retry, the stopped message's: it sends only that one. + fireEvent.click(screen.getByRole('button', { name: /Retry/ })) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({ + envelope: { clientOperationId: 'op-head' } + }) + + // The queue moved, so the rejected message offers its own Retry again. + await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) + expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy() + expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(1) + expect(mocks.call).toHaveBeenCalledOnce() + }) + + it("words a failed start on each message by the chat's agent, leaving the resend to its Retry", async () => { + mocks.mode = 'outbox' + mocks.submissions = [] + const startFailed = (clientMessageId: string, text: string) => ({ + ...seededEntry('session-start-failed', clientMessageId, text, 'queued'), + state: 'rejected' as const, + lastFailure: { + kind: 'rejected' as const, + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' as const } + } + }) + seedOutbox('session-start-failed', [ + startFailed('op-first', 'first'), + startFailed('op-second', 'second') + ]) + + render( + + ) + + await waitFor(() => + expect(screen.getAllByText('Codex stopped before it finished starting.')).toHaveLength(2) + ) + expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(2) + expect(screen.queryByText(/Send your message to try again/)).toBeNull() + }) + + it("words a rejected message from its loaded journal row, not the message's own copy", async () => { + mocks.mode = 'outbox' + const reason = "Claude couldn't start. Send your message to try again." + mocks.submissions = [ + { + clientMessageId: 'op-recorded', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason, + rejection: { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } + }, + submittedAt: 1, + resolvedAt: 1 + } + ] + seedOutbox('session-recorded', [ + { + ...seededEntry('session-recorded', 'op-recorded', 'first', 'queued'), + state: 'rejected', + lastFailure: { kind: 'rejected', reason, rejection: { kind: 'startFailed' } } + } + ]) + + render( + + ) + + await waitFor(() => + expect(screen.getByText("Codex couldn't start. Start a new chat to continue.")).toBeTruthy() + ) + expect(screen.queryByText(reason)).toBeNull() + }) + it('names the stuck message behind an admitted head, and its Retry sends that one', async () => { mocks.mode = 'outbox' mocks.submissions = [] diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx index 86a2b858a42..2f33b0ed0c3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx @@ -18,6 +18,8 @@ export function NativeChatStructuredSessionStatus(props: { /** The host's word on the provider child; `starting` is published but not yet answering. */ startupPhase: 'starting' | 'ready' | null error: string | null + /** A read the pane is reconnecting on its own: said plainly, not as an error. */ + reconnecting?: boolean composerError: string | null isVisible: boolean backgroundTasks: StructuredSessionBackgroundTasksView @@ -68,6 +70,11 @@ export function NativeChatStructuredSessionStatus(props: { )}

) : null} + {props.reconnecting && !props.error ? ( +

+ {translate('components.native-chat.state.reconnecting', 'Reconnecting to this chat…')} +

+ ) : null} {props.error || props.composerError ? (

{props.error ?? props.composerError} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index feb2ea7721d..fb58acb7280 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -1,7 +1,7 @@ import { memo } from 'react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -import { MessageRow } from './NativeChatMessageRow' +import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow' import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt' import { NativeChatWorkingStatus } from './NativeChatWorkingStatus' import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup' @@ -17,7 +17,8 @@ export type NativeChatTranscriptRowContext = { revealedDiff: NativeChatDiffReveal | null taskListPredecessors: ReadonlyMap expandedTurnIds: ReadonlySet - failedDeliveryMessageIds?: ReadonlySet + /** Keyed by message id: the user messages that did not go through, each with its own words. */ + deliveryNotices?: ReadonlyMap allowFileUriLinks: boolean runtimeContext?: RuntimeFileOperationArgs | null onLinkClick?: CommentMarkdownLinkClickHandler @@ -60,7 +61,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ onScrollMessageToTop={context.onScrollMessageToTop} onLinkClick={context.onLinkClick} allowFileUriLinks={context.allowFileUriLinks} - deliveryFailed={context.failedDeliveryMessageIds?.has(message.id) === true} + deliveryNotice={context.deliveryNotices?.get(message.id)} structuredActivityUi={context.showTurnStatus} folded={slot.folded} runtimeContext={context.runtimeContext} @@ -69,7 +70,6 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ {status ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx index c610abff7d6..53e31157075 100644 --- a/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatWorkingStatus.tsx @@ -9,27 +9,22 @@ import { useNativeChatElapsedSeconds } from './use-native-chat-elapsed-seconds' export { formatNativeChatDuration } +/** The turn bar under the user's message: a running clock while the turn works, + * then the settled duration, which toggles the turn's folded detail. */ export function NativeChatWorkingStatus({ startedAt, - thinking, workedSeconds, expanded = false, onToggleExpanded }: { startedAt: number | null - thinking: boolean workedSeconds?: number | null expanded?: boolean onToggleExpanded?: () => void }): React.JSX.Element { - const counting = !thinking && workedSeconds == null - const elapsedSeconds = useNativeChatElapsedSeconds(startedAt, counting) + const elapsedSeconds = useNativeChatElapsedSeconds(startedAt, workedSeconds == null) - const { key, duration } = describeNativeChatTurnStatus({ - thinking, - workedSeconds, - elapsedSeconds - }) + const { key, duration } = describeNativeChatTurnStatus({ workedSeconds, elapsedSeconds }) const label = key === 'workedFor' ? translate( @@ -39,15 +34,14 @@ export function NativeChatWorkingStatus({ value0: duration } ) - : key === 'thinking' - ? translate('components.native-chat.status.thinking', NATIVE_CHAT_TURN_STATUS_COPY.thinking) - : translate( - 'components.native-chat.status.workingFor', - NATIVE_CHAT_TURN_STATUS_COPY.workingFor, - { value0: duration } - ) + : translate( + 'components.native-chat.status.workingFor', + NATIVE_CHAT_TURN_STATUS_COPY.workingFor, + { value0: duration } + ) // `tabular-nums`: the live clock reflows its own label every second otherwise. - const className = `flex min-h-8 items-center gap-1 text-sm text-muted-foreground tabular-nums${thinking ? '' : ' border-b border-border'}` + const className = + 'flex min-h-8 items-center gap-1 border-b border-border text-sm text-muted-foreground tabular-nums' const caret = workedSeconds != null && onToggleExpanded ? ( - {label} + {label} {caret}

) diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts index c4cbe3fc392..260c910d01e 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { i18n } from '@/i18n/i18n' import en from '@/i18n/locales/en.json' +import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice' import { AGENT_SESSION_WRITE_NOTICE_COPY, - agentSessionWriteNoticeEnglish, type AgentSessionWriteNoticeSentence -} from '../../../../shared/agent-session-refusal-notice' +} from '../../../../shared/agent-session-write-notice-copy' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' const SENTENCES = Object.keys(AGENT_SESSION_WRITE_NOTICE_COPY).filter( diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index b8d4b919db8..812197d6e33 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -2,16 +2,20 @@ // shared English as its fallback so desktop and mobile never say it differently. import { translate } from '@/i18n/i18n' +import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' import { AGENT_SESSION_WRITE_NOTICE_COPY as COPY, - agentSessionWriteNoticeParts, - type AgentSessionWriteFailure, - type AgentSessionWriteKind, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence -} from '../../../../shared/agent-session-refusal-notice' +} from '../../../../shared/agent-session-write-notice-copy' +import type { + AgentSessionWriteFailure, + AgentSessionWriteKind +} from '../../../../shared/agent-session-write-failure' const SENTENCES: Record string> = { + notDoneReadHistory: () => + translate('components.native-chat.writeNotice.notDoneReadHistory', COPY.notDoneReadHistory), notDoneSend: () => translate('components.native-chat.writeNotice.notDoneSend', COPY.notDoneSend), tryAgainComposerSend: () => translate('components.native-chat.writeNotice.tryAgainComposerSend', COPY.tryAgainComposerSend), @@ -36,8 +40,76 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.questionChanged', COPY.questionChanged), historyUnreadable: () => translate('components.native-chat.writeNotice.historyUnreadable', COPY.historyUnreadable), + historyUnusable: () => + translate('components.native-chat.writeNotice.historyUnusable', COPY.historyUnusable), + historyUnavailable: () => + translate('components.native-chat.writeNotice.historyUnavailable', COPY.historyUnavailable), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), - unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable) + unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), + recordFailed: () => + translate('components.native-chat.writeNotice.recordFailed', COPY.recordFailed), + conversationCleared: () => + translate('components.native-chat.writeNotice.conversationCleared', COPY.conversationCleared), + openCurrentConversation: () => + translate( + 'components.native-chat.writeNotice.openCurrentConversation', + COPY.openCurrentConversation + ), + clearUnfinished: () => + translate('components.native-chat.writeNotice.clearUnfinished', COPY.clearUnfinished), + commandRunning: () => + translate('components.native-chat.writeNotice.commandRunning', COPY.commandRunning), + waitForCommand: () => + translate('components.native-chat.writeNotice.waitForCommand', COPY.waitForCommand), + agentStarting: () => + translate('components.native-chat.writeNotice.agentStarting', COPY.agentStarting), + waitForStart: () => + translate('components.native-chat.writeNotice.waitForStart', COPY.waitForStart), + turnActive: () => translate('components.native-chat.writeNotice.turnActive', COPY.turnActive), + waitForTurn: () => translate('components.native-chat.writeNotice.waitForTurn', COPY.waitForTurn), + promptPending: () => + translate('components.native-chat.writeNotice.promptPending', COPY.promptPending), + answerFirst: () => translate('components.native-chat.writeNotice.answerFirst', COPY.answerFirst), + backgroundTasksRunning: () => + translate( + 'components.native-chat.writeNotice.backgroundTasksRunning', + COPY.backgroundTasksRunning + ), + waitForBackgroundTasks: () => + translate( + 'components.native-chat.writeNotice.waitForBackgroundTasks', + COPY.waitForBackgroundTasks + ), + messagesUnsettled: () => + translate('components.native-chat.writeNotice.messagesUnsettled', COPY.messagesUnsettled), + settleEarlierMessage: () => + translate('components.native-chat.writeNotice.settleEarlierMessage', COPY.settleEarlierMessage), + optionRejected: () => + translate('components.native-chat.writeNotice.optionRejected', COPY.optionRejected), + goalsUnsupported: () => + translate('components.native-chat.writeNotice.goalsUnsupported', COPY.goalsUnsupported), + agentRefused: () => + translate('components.native-chat.writeNotice.agentRefused', COPY.agentRefused), + ownerUnproven: () => + translate('components.native-chat.writeNotice.ownerUnproven', COPY.ownerUnproven), + reopenChat: () => translate('components.native-chat.writeNotice.reopenChat', COPY.reopenChat), + terminalAgentHoldsChat: () => + translate( + 'components.native-chat.writeNotice.terminalAgentHoldsChat', + COPY.terminalAgentHoldsChat + ), + quitTerminalAgent: () => + translate('components.native-chat.writeNotice.quitTerminalAgent', COPY.quitTerminalAgent), + hostReconciling: () => + translate('components.native-chat.writeNotice.hostReconciling', COPY.hostReconciling), + waitMoment: () => translate('components.native-chat.writeNotice.waitMoment', COPY.waitMoment), + recordUnreadable: () => + translate('components.native-chat.writeNotice.recordUnreadable', COPY.recordUnreadable), + chatNotFound: () => + translate('components.native-chat.writeNotice.chatNotFound', COPY.chatNotFound), + startNewChat: () => + translate('components.native-chat.writeNotice.startNewChat', COPY.startNewChat), + tryAgain: () => translate('components.native-chat.writeNotice.tryAgain', COPY.tryAgain) } export function agentSessionWriteNoticeText(parts: readonly AgentSessionWriteNoticePart[]): string { diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index eedf8506e1c..334e4639add 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -147,7 +147,7 @@ export function createClaudeModelSwitchConfirmationObserver(args: { // exact Claude warning defaults to “Yes” and needs only one Enter. const accepted = args.submitConfirmation ? args.submitConfirmation() !== false - : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT) + : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT, 'driving') if (!accepted) { finish('unknown') return diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index 3057be1f908..385899ae91f 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -60,7 +60,8 @@ const JOURNAL: AgentJournalRenderItem[] = [ ]), row(10, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Done.' }] }), user(11, [{ type: 'text', text: 'Thanks' }]), - // Journalled after `Thanks` but observed before `Done.`: the transcript orders by observation. + // Recovered after a crash: journalled after `Thanks`, but carrying the provider's clock from + // before `Done.`. The transcript orders by journal position, never observation. { ...user(12, [{ type: 'text', text: 'Observed earlier' }]), observedAt: 1_009.5 } ] @@ -107,13 +108,13 @@ describe('conversation outline parity with the loaded rail', () => { })) ).toEqual(loaded.map(({ id, text, hasImages }) => ({ id, text, hasImages }))) // Anti-vacuous: the folded tool result, the refused send, the harness turn and the empty - // prompt were all dropped, and the late-journalled row sits where it was observed. + // prompt were all dropped, and the recovered row sits where it was journalled. expect(outline.map((entry) => entry.itemId)).toEqual([ 'item-1', 'item-5', 'item-9', - 'item-12', - 'item-11' + 'item-11', + 'item-12' ]) }) @@ -123,8 +124,8 @@ describe('conversation outline parity with the loaded rail', () => { { itemId: 'item-1', sequence: 1, preview: 'Fix the parser', imageCount: 0 }, { itemId: 'item-5', sequence: 5, preview: '', imageCount: 1 }, { itemId: 'item-9', sequence: 9, preview: 'Compare these', imageCount: 2 }, - { itemId: 'item-12', sequence: 12, preview: 'Observed earlier', imageCount: 0 }, - { itemId: 'item-11', sequence: 11, preview: 'Thanks', imageCount: 0 } + { itemId: 'item-11', sequence: 11, preview: 'Thanks', imageCount: 0 }, + { itemId: 'item-12', sequence: 12, preview: 'Observed earlier', imageCount: 0 } ]) }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts index f77ba77e274..aba65acf8db 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts @@ -56,20 +56,20 @@ export function sendNativeChatMessageWithImageAttachments( ), trimmedText.length > 0 )) { - sendRuntimePtyInput(settings, ptyId, payload) + sendRuntimePtyInput(settings, ptyId, payload, 'driving') } if (trimmedText.length > 0) { delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving') delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) return } delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index d8cf90167ec..0071b73b21d 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -138,7 +138,12 @@ describe('sendNativeChatMessage', () => { ]) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6) }) @@ -194,13 +199,19 @@ describe('sendNativeChatMessageVerified', () => { expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( SETTINGS, PTY, - buildNativeChatPasteBytes('/model sonnet') + buildNativeChatPasteBytes('/model sonnet'), + 'driving' ) await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) expect(await result).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect( sendRuntimePtyInputVerified.mock.calls.some( (call) => call[2] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT @@ -234,7 +245,12 @@ describe('sendNativeChatMessageVerified', () => { const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[2] === NATIVE_CHAT_SUBMIT) // Only the verified path's Enter — chat's delayed Enter was cancelled. expect(submits).toHaveLength(0) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('returns false when the delayed Enter wait is aborted', async () => { @@ -347,9 +363,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => { '\x1b[200~@"C:\\Images\\screen shot.png"\x1b[201~ ' ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe') + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'describe', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('clears the line, then bracket-pastes image paths before prompt text', () => { @@ -372,10 +393,20 @@ describe('sendNativeChatMessageWithImageAttachments', () => { ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, 'what do you see?') + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + 'what do you see?', + 'driving' + ) vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4) }) @@ -396,7 +427,12 @@ describe('sendNativeChatMessageWithImageAttachments', () => { vi.advanceTimersByTime(1) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( + SETTINGS, + PTY, + NATIVE_CHAT_SUBMIT, + 'driving' + ) }) it('treats whitespace-only prompt input as attachment-only', () => { @@ -450,7 +486,7 @@ describe('empty prompt submit', () => { it('submits an empty prompt with a bare Enter', () => { submitNativeChatPrompt(SETTINGS, PTY) expect(sendRuntimePtyInput).toHaveBeenCalledOnce() - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT, 'driving') }) }) @@ -483,16 +519,17 @@ describe('sendNativeChatAskAnswer', () => { ) vi.advanceTimersByTime(0) - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1') + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) - expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2') + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving') vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( SETTINGS, PTY, - buildNativeChatPasteBytes('custom answer') + buildNativeChatPasteBytes('custom answer'), + 'driving' ) }) @@ -533,7 +570,7 @@ describe('sendNativeChatAskAnswer', () => { const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }], onSettled) await vi.advanceTimersByTimeAsync(handle.settleAfterMs) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, '2', 'driving') expect(onSettled).not.toHaveBeenCalled() resolveAccepted(true) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a845fe10d16..1eb7a676103 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -72,7 +72,12 @@ export function clearUnsubmittedAgentInput( ptyId: string, options?: NativeChatSendOptions ): void { - sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) + sendRuntimePtyInput( + settings, + ptyId, + options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + 'driving' + ) } /** @@ -104,7 +109,7 @@ export function clearThenWrite( // An unreadable terminal is unconfirmed; the maximal clear remains safe. } if (!cleared) { - sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX) + sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving') } writeBody() }) @@ -140,11 +145,11 @@ export function sendNativeChatMessage( if (isCancelled()) { return } - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text), 'driving') // Schedule from the actual body write: an overdue clear-confirm callback // must not collapse the required body-to-Enter gap after a renderer stall. delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') markSubmitted() }) }) @@ -203,12 +208,13 @@ export async function sendNativeChatMessageVerified( const bodyAccepted = await sendRuntimePtyInputVerified( settings, ptyId, - buildNativeChatPasteBytes(text) + buildNativeChatPasteBytes(text), + 'driving' ) if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) { return false } - return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) + return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') } /** Types a slash command as individual keys so Codex opens its command palette. */ @@ -224,7 +230,7 @@ export async function typeNativeChatCommand( command, signal, write: async (key) => - (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) ? 'accepted' : 'rejected' }) return outcome === 'accepted' } @@ -253,7 +259,9 @@ export function sendNativeChatTypedCommand( if (isCancelled()) { return 'rejected' } - return (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + return (await sendRuntimePtyInputVerified(settings, ptyId, key, 'driving')) + ? 'accepted' + : 'rejected' } }).then(finish, () => finish('rejected')) }, @@ -269,7 +277,7 @@ export function sendNativeChatTypedCommand( /** Submit a TUI prompt with no body (Enter only) — e.g. a plain submit when the * composer is empty. */ export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT, 'driving') } /** @@ -297,10 +305,10 @@ export function sendNativeChatAskAnswer( // Why: inference must use the remote host's acceptance result, not // the fire-and-forget renderer dispatch result. verifiedWrites.push( - sendRuntimePtyInputVerified(settings, ptyId, bytes).catch(() => false) + sendRuntimePtyInputVerified(settings, ptyId, bytes, 'driving').catch(() => false) ) } else { - sendRuntimePtyInput(settings, ptyId, bytes) + sendRuntimePtyInput(settings, ptyId, bytes, 'driving') } }, index * NATIVE_CHAT_QUESTION_STEP_MS) ) diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index eb61380967f..674e14d6bf8 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -7,7 +7,7 @@ import { type NativeChatSessionStatus } from '../../../../shared/native-chat-types' import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' -import { compareNativeChatMessagesByTime } from '../../../../shared/native-chat-transcript-projection' +import { compareNativeChatTranscriptMessages } from '../../../../shared/native-chat-transcript-projection' import { hasImagePromptMarker, isImageSourceUserTurn, @@ -105,14 +105,14 @@ function messageSortRank(message: NativeChatMessage): number { return 0 } -// Rank first; within a tier the transcript's shared time order. +// Rank first; within a tier the transcript's shared order. export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): number { const ar = messageSortRank(a) const br = messageSortRank(b) if (ar !== br) { return ar - br } - return compareNativeChatMessagesByTime(a, b) + return compareNativeChatTranscriptMessages(a, b) } /** diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index ecb6a183f4e..f22d25bfa8f 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -139,14 +139,15 @@ describe('transcript slots', () => { expect(slots[0]?.receipt).toBe(receipt) }) - it('leaves the running turn status to the single transcript-tail indicator', () => { + it('puts the running turn bar under the prompt it answers', () => { const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: null } - const slots = build([text('u', 'ask', 'user')], { + const slots = build([text('u', 'ask', 'user'), text('a', 'answer')], { latestUserIndex: 0, turnStatuses: { active: status, completedByTurn: {} }, isWorking: true }) - expect(slots[0]?.status).toBeUndefined() + expect(slots[0]?.status).toBe(status) + expect(slots[1]?.status).toBeUndefined() }) it('reserves a height for every slot it keeps', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index bf1b3f3e75c..a0fe23b6df3 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -135,8 +135,8 @@ export function buildNativeChatTranscriptSlots( : message.role === 'user' && turnKey ? turnStatuses.completedByTurn[turnKey] : undefined - const status = - showTurnStatus && candidateStatus?.workedSeconds != null ? candidateStatus : undefined + // The live turn's bar carries its running clock; it settles in place. + const status = showTurnStatus ? (candidateStatus ?? undefined) : undefined const turnDiff = turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined const folded = foldedRows.has(index) // Skipping a folded row entirely is what keeps windowing honest: a counted diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts index ea97ec99a19..39bf20322b5 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.test.ts @@ -34,7 +34,30 @@ describe('selectNativeChatViewState', () => { }) }) - it('maps error with its message', () => { + it('maps error with its message when nothing has loaded (P2-21)', () => { + const state = selectNativeChatViewState( + session({ messages: [], status: 'error', error: 'boom' }) + ) + expect(state).toEqual({ kind: 'error', message: 'boom' }) + }) + + // The pane's own translated line fills in, which for the structured chat says the read retries. + it('maps an error with no text to no message', () => { + expect(selectNativeChatViewState(session({ messages: [], status: 'error' }))).toEqual({ + kind: 'error' + }) + }) + + it('keeps a loaded transcript on screen when a later read fails (P2-21)', () => { + const state = selectNativeChatViewState(session({ status: 'error', error: 'boom' }), { + readRetries: true + }) + expect(state).toEqual({ kind: 'ready', isWorking: false }) + }) + + // The terminal-backed read does not retry; its only messages on error are local echoes (a launch + // prompt, a pending send), which must not hide the error and its way back to the terminal. + it('shows a terminal-backed read error over local echoes', () => { const state = selectNativeChatViewState(session({ status: 'error', error: 'boom' })) expect(state).toEqual({ kind: 'error', message: 'boom' }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.ts b/src/renderer/src/components/native-chat/native-chat-view-state.ts index 775635df122..13a7e8a5afe 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.ts @@ -10,23 +10,36 @@ import type { NativeChatSession } from '../../../../shared/native-chat-types' * live in-flight indicator. The rest are full-pane states. */ export type NativeChatViewState = | { kind: 'loading' } - | { kind: 'error'; message: string } + | { kind: 'error'; message?: string } | { kind: 'empty' } | { kind: 'ready'; isWorking: false } | { kind: 'ready'; isWorking: true } /** - * Decide which surface to render. Error is terminal, but any renderable message - * wins over loading/empty so optimistic first sends never get replaced by a - * full-pane placeholder while transcript discovery catches up. + * Decide which surface to render. Any renderable message wins over loading/empty so optimistic + * first sends never get replaced while transcript discovery catches up. Where the read retries on + * its own (the structured chat), messages win over an error too: a read that fails after the + * transcript loaded keeps it, and the error reaches the composer's error line. The terminal-backed + * read does not retry, and its only messages on error are local echoes, so its error takes the pane. */ -export function selectNativeChatViewState(session: NativeChatSession): NativeChatViewState { - if (session.status === 'error') { - return { kind: 'error', message: session.error ?? 'Conversation could not be loaded.' } +export function selectNativeChatViewState( + session: NativeChatSession, + { readRetries = false }: { readRetries?: boolean } = {} +): NativeChatViewState { + // No text of its own: the empty state supplies the pane's translated line. + const error: NativeChatViewState | null = + session.status === 'error' + ? { kind: 'error', ...(session.error ? { message: session.error } : {}) } + : null + if (error && !readRetries) { + return error } if (session.messages.length > 0) { return { kind: 'ready', isWorking: session.status === 'working' } } + if (error) { + return error + } if (session.status === 'loading') { return { kind: 'loading' } } diff --git a/src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx b/src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx index e4bae292004..8600d40c3ca 100644 --- a/src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-working-status-shared-clock.test.tsx @@ -20,7 +20,7 @@ function renderTurns(count: number, startedAt: number): void { root.render( <> {Array.from({ length: count }, (_, index) => ( - + ))} ) @@ -61,9 +61,7 @@ describe('native chat working status elapsed clock', () => { it('renders the compact duration in the completed status label', () => { act(() => { - root.render( - - ) + root.render() }) expect(elapsedLabels()).toEqual(['Worked for 1m 9s']) @@ -96,14 +94,9 @@ describe('native chat working status elapsed clock', () => { expect(vi.getTimerCount()).toBe(1) }) - it('holds no timer for a thinking turn or a completed turn', () => { + it('holds no timer for a completed turn', () => { act(() => { - root.render( - <> - - - - ) + root.render() }) expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-question-projection.ts b/src/renderer/src/components/native-chat/structured-agent-question-projection.ts index b56fa1c7ab0..9469c443d0a 100644 --- a/src/renderer/src/components/native-chat/structured-agent-question-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-question-projection.ts @@ -4,6 +4,7 @@ import type { } from '../../../../shared/agent-session-journal-types' import { isAskUserQuestionTool } from '../../../../shared/agent-question-answered-intent' import { parseAskFromToolInput } from '../../../../shared/native-chat-ask' +import { agentJournalItemRowOrigin } from '../../../../shared/agent-session-journal-position' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' import { readAgentJournalTurn } from '../../../../shared/agent-session-turn-record' @@ -69,10 +70,8 @@ function projectItem(item: AgentJournalRenderItem): Projection { if (body.resolution.state === 'pending') { // A system row preserves question identity through tool folding; the receipt renders its body. message = { - id: item.itemId, + ...agentJournalItemRowOrigin(item), role: 'system', - timestamp: item.observedAt, - source: 'transcript', blocks: [{ type: 'text', text: body.question }] } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts new file mode 100644 index 00000000000..faadac1127e --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -0,0 +1,414 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { + structuredAgentSessionDeliveryNotices, + structuredAgentSessionStartFailureFacts +} from './structured-agent-session-delivery-notices' + +function entry( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function texts( + outbox: StructuredAgentSessionOutboxEntry[], + blocked: string | null = null, + submissions: readonly AgentJournalSubmission[] = [], + startFailures: readonly AgentSessionFailureFact[] = [] +): Record { + const notices = structuredAgentSessionDeliveryNotices( + outbox, + blocked, + 'Claude', + () => {}, + submissions, + startFailures + ) + return Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text])) +} + +describe('the notice on each message that did not go through', () => { + it('gives two failed messages each their own reason and their own Retry', () => { + const retry = vi.fn() + const notices = structuredAgentSessionDeliveryNotices( + [ + entry('first', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } + }), + entry('second', { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'Claude never finished starting, so Orca stopped it.', + rejection: { kind: 'hostStopped' } + } + }) + ], + null, + 'Claude', + retry, + [], + [] + ) + + expect([...notices.keys()]).toEqual([ + agentJournalSubmissionKey('first'), + agentJournalSubmissionKey('second') + ]) + expect(notices.get(agentJournalSubmissionKey('first'))?.text).toBe( + 'Claude messages support at most 20 images' + ) + expect(notices.get(agentJournalSubmissionKey('second'))?.text).toBe( + 'Claude never finished starting, so Orca stopped it.' + ) + notices.get(agentJournalSubmissionKey('second'))?.onRetry?.() + expect(retry).toHaveBeenCalledExactlyOnceWith('second') + }) + + it('chooses the words from the saved refusal on the message the queue stopped on', () => { + expect( + texts( + [ + entry('held', { + lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } + }) + ], + 'held' + ) + ).toEqual({ + [agentJournalSubmissionKey('held')]: "The agent couldn't restart. Your message was not sent." + }) + }) + + // The same rule as a rejected row's: its own Retry is the resend step, and any other step stays. + it('leaves a retry step to the Retry beside the message the queue stopped on', () => { + const held = (lastFailure: StructuredAgentSessionOutboxEntry['lastFailure']) => + texts([entry('held', { lastFailure })], 'held') + expect( + held({ + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + }) + ).toEqual({ + [agentJournalSubmissionKey('held')]: + "Orca couldn't open this chat's history right now. Your message was not sent." + }) + expect( + held({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }) + ).toEqual({ + [agentJournalSubmissionKey('held')]: + 'Your message was not sent. Claude is not signed in for the selected account. Sign in first.' + }) + }) + + it('says a message is unconfirmed, and only that it was not sent when nothing more is known', () => { + expect(texts([entry('doubt', { state: 'unconfirmed' })])).toEqual({ + [agentJournalSubmissionKey('doubt')]: 'Message delivery is unconfirmed.' + }) + expect(texts([entry('bare')], 'bare')).toEqual({ + [agentJournalSubmissionKey('bare')]: 'Message was not sent.' + }) + }) + + // The drain's own rule: a message behind the one the queue stopped on is only waiting, so it says + // nothing. A rejected message holds nothing up and keeps its words. + it('says why on the message the queue stopped on and on every rejected one', () => { + expect( + texts([ + entry('sent', { state: 'dispatching' }), + entry('rejected', { state: 'rejected' }), + entry('stuck', { state: 'unconfirmed' }), + entry('behind', { state: 'unconfirmed' }), + entry('queued') + ]) + ).toEqual({ + [agentJournalSubmissionKey('rejected')]: 'Message was not sent.', + [agentJournalSubmissionKey('stuck')]: 'Message delivery is unconfirmed.' + }) + }) + + // Its Retry would put it back in the queue to wait unseen behind the stopped message. + it('keeps a rejected message its words but not its Retry while the queue is stopped', () => { + const retry = vi.fn() + for (const [outbox, blocked] of [ + [[entry('stuck', { state: 'unconfirmed' }), entry('rejected', { state: 'rejected' })], null], + [[entry('rejected', { state: 'rejected' }), entry('held')], 'held'] + ] as const) { + const notices = structuredAgentSessionDeliveryNotices( + [...outbox], + blocked, + 'Claude', + retry, + [], + [] + ) + expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({ + text: 'Message was not sent.' + }) + } + }) + + // Beside its own Retry the resend step is the button; without one the words keep it. + it('leaves out sending again only where the message has its own Retry', () => { + const startFailed = (clientMessageId: string): StructuredAgentSessionOutboxEntry => + entry(clientMessageId, { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'Claude stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' } + } + }) + expect(texts([startFailed('first'), startFailed('second')])).toEqual({ + [agentJournalSubmissionKey('first')]: 'Claude stopped before it finished starting.', + [agentJournalSubmissionKey('second')]: 'Claude stopped before it finished starting.' + }) + expect(texts([startFailed('rejected'), entry('held')], 'held')).toMatchObject({ + [agentJournalSubmissionKey('rejected')]: + 'Claude stopped before it finished starting. Send your message to try again.' + }) + }) + + it.each([ + [ + 'notDelivered', + 'This message was not delivered. Send it again to continue.', + 'This message was not delivered.' + ], + [ + 'hostFault', + "Orca ran into a problem, so this didn't go through. Try again.", + "Orca ran into a problem, so this didn't go through." + ] + ] as const)('leaves the step to the Retry beside a %s message', (kind, reason, shown) => { + expect( + texts([ + entry('rejected', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason, rejection: { kind } } + }) + ]) + ).toEqual({ [agentJournalSubmissionKey('rejected')]: shown }) + }) + + // The journal holds the whole fact; the message's own copy keeps only its kind and attachment. + it("words a recorded rejection from the journal's fact, whatever reason the host wrote", () => { + const rejected = (id: string): StructuredAgentSessionOutboxEntry => + entry(id, { + state: 'rejected', + // An older host's words, and not the pane's agent name: never compared, never shown. + lastFailure: { + kind: 'rejected', + reason: "The agent couldn't be started.", + rejection: { kind: 'startFailed' } + } + }) + const recorded = (id: string, rejection: AgentSessionFailureFact): AgentJournalSubmission => ({ + clientMessageId: id, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: "The agent couldn't be started.", + rejection, + submittedAt: 1, + resolvedAt: 1 + }) + const facts: [string, AgentSessionFailureFact, string][] = [ + [ + 'gone', + { + kind: 'startFailed', + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'recordMissing' } + } + }, + "Claude couldn't start. Start a new chat to continue." + ], + [ + 'claimed', + { + kind: 'startFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + }, + "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ], + [ + 'resumable', + { kind: 'startFailed', refusal: { code: 'agent_session_ownership_unknown' } }, + "Claude couldn't start." + ], + [ + 'provider', + { kind: 'providerRejected', detail: { text: 'Image type .bmp', audience: 'person' } }, + 'The provider did not accept this message: Image type .bmp.' + ], + [ + 'logged', + { kind: 'providerRejected', detail: { text: 'HTTP 400 at /v1', audience: 'log' } }, + 'The provider did not accept this message.' + ] + ] + expect( + texts( + facts.map(([id]) => rejected(id)), + null, + facts.map(([id, fact]) => recorded(id, fact)) + ) + ).toEqual( + Object.fromEntries(facts.map(([id, , shown]) => [agentJournalSubmissionKey(id), shown])) + ) + // Not loaded (older than the loaded page): the message's copy has no refusal or detail, so the + // host's sentence, which holds them, is shown for those kinds; the table words the rest. + expect( + texts([ + rejected('gone'), + entry('provider', { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'The provider did not accept this message: Image type .bmp.', + rejection: { kind: 'providerRejected' } + } + }), + entry('stopped', { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'The agent stopped before this message was sent.', + rejection: { kind: 'providerExited' } + } + }) + ]) + ).toEqual({ + [agentJournalSubmissionKey('gone')]: "The agent couldn't be started.", + [agentJournalSubmissionKey('provider')]: + 'The provider did not accept this message: Image type .bmp.', + [agentJournalSubmissionKey('stopped')]: 'Claude stopped before this message was sent.' + }) + }) + + it('says nothing on a message that is only waiting its turn or on its way', () => { + expect(texts([entry('queued'), entry('sending', { state: 'dispatching' })])).toEqual({}) + }) + + // Matched on the typed fact of a row found by its identity, never on either sentence. + describe('a message rejected by a start whose row already says why', () => { + const startFailed: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } + } + const rejected = (id: string, fact: AgentSessionFailureFact) => + entry(id, { + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'Written by the host.', + rejection: { kind: fact.kind } + } + }) + const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission => ({ + clientMessageId: id, + fence: 1, + payloadFingerprint: id, + dispatchState: 'rejected', + providerItemId: null, + reason: 'Written by the host.', + rejection: fact, + submittedAt: 1, + resolvedAt: 1 + }) + const statusRow = (itemId: string, fact: AgentSessionFailureFact): AgentJournalRenderItem => ({ + itemId, + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } + }) + const startRowKey = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')) + + it('reads only the start-failure rows', () => { + expect( + structuredAgentSessionStartFailureFacts([ + statusRow(startRowKey, startFailed), + statusRow(agentJournalSubmissionKey('exit-row'), { kind: 'providerExited' }) + ]) + ).toEqual([startFailed]) + }) + + it('says only that each was not sent, and words any other rejection in full', () => { + const otherRefusal: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + } + const facts = structuredAgentSessionStartFailureFacts([statusRow(startRowKey, startFailed)]) + expect( + texts( + [ + rejected('first', startFailed), + rejected('second', startFailed), + rejected('other', otherRefusal) + ], + null, + [ + recorded('first', startFailed), + recorded('second', startFailed), + recorded('other', otherRefusal) + ], + facts + ) + ).toEqual({ + [agentJournalSubmissionKey('first')]: 'Your message was not sent.', + [agentJournalSubmissionKey('second')]: 'Your message was not sent.', + [agentJournalSubmissionKey('other')]: + "Claude couldn't start. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + }) + }) + + it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { + const shown = "Claude couldn't start. Start a new chat to continue." + expect(texts([rejected('first', startFailed)], null, [], [startFailed])).toEqual({ + [agentJournalSubmissionKey('first')]: 'Written by the host.' + }) + expect( + texts([rejected('first', startFailed)], null, [recorded('first', startFailed)], []) + ).toEqual({ [agentJournalSubmissionKey('first')]: shown }) + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts new file mode 100644 index 00000000000..ec3eb60e687 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -0,0 +1,137 @@ +// Which of the structured chat's own messages say, on their row, that they did not go through. +// +// Derived from the outbox on every render and never stored: each failed or held message carries +// its own typed failure, so each row words its own reason. Read through the drain's own rule: while +// the queue is stopped, only the message it stopped on has a Retry; another's would wait unseen +// behind it. One waiting behind says nothing; a rejected message holds nothing up, so it keeps its +// words and gets its Retry once the queue moves. +// +// A message the host recorded and then rejected is worded from the journal's own fact, found by id; +// the message keeps only a smaller copy, read when its submission is not loaded. A rejection that +// is a failed start's, the fact its loaded row states, says only that it was not sent: the row +// already says why. + +import { + readAgentSessionFailureFact, + type AgentSessionFailureFact +} from '../../../../shared/agent-session-failure' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' +import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { + admitStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words' +import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' +import { translate } from '@/i18n/i18n' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' + +/** The facts the chat's loaded start-failure rows state. */ +export function structuredAgentSessionStartFailureFacts( + items: readonly AgentJournalRenderItem[] +): AgentSessionFailureFact[] { + const facts: AgentSessionFailureFact[] = [] + for (const item of items) { + if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { + const fact = readAgentSessionFailureFact(item.body.failure) + if (fact) { + facts.push(fact) + } + } + } + return facts +} + +/** Whether two facts are one failure: a start's row and the messages it rejected share one. */ +export function sameAgentSessionFailureFact( + a: AgentSessionFailureFact, + b: AgentSessionFailureFact +): boolean { + return ( + a.kind === b.kind && + a.detail?.text === b.detail?.text && + a.detail?.audience === b.detail?.audience && + a.refusal?.code === b.refusal?.code && + a.refusal?.details?.reason === b.refusal?.details?.reason && + a.attachment?.reason === b.attachment?.reason && + a.attachment?.limit === b.attachment?.limit && + a.retry?.error === b.retry?.error && + a.retry?.status === b.retry?.status + ) +} + +function deliveryNoticeText( + entry: StructuredAgentSessionOutboxEntry, + context: AgentSessionFailureWordsContext, + recorded: AgentJournalSubmission | undefined, + startFailures: readonly AgentSessionFailureFact[] +): string { + if (entry.state === 'unconfirmed') { + return translate( + 'auto.components.native.chat.NativeChatStructuredSession.1f772bb5d0', + 'Message delivery is unconfirmed.' + ) + } + if (!entry.lastFailure) { + return translate( + 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', + 'Message was not sent.' + ) + } + const fact = readAgentSessionFailureFact(recorded?.rejection) + if ( + entry.state === 'rejected' && + fact && + startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact)) + ) { + return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) + } + return agentSessionWriteNoticeText( + structuredAgentSessionAttemptFailureParts(entry.lastFailure, context, fact) + ) +} + +/** Keyed by the message id the transcript renders each entry under. `blockedClientMessageId` is + * the entry a refusal stopped the queue on; `agentName` is the chat's agent, for the words. */ +export function structuredAgentSessionDeliveryNotices( + outbox: readonly StructuredAgentSessionOutboxEntry[], + blockedClientMessageId: string | null, + agentName: string, + retry: (clientMessageId: string) => void, + /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ + submissions: readonly AgentJournalSubmission[], + /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ + startFailures: readonly AgentSessionFailureFact[] +): ReadonlyMap { + const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) + const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null + const rejected = new Map( + submissions + .filter((submission) => submission.dispatchState === 'rejected') + .map((submission) => [submission.clientMessageId, submission]) + ) + const notices = new Map() + for (const entry of outbox) { + if (entry.state === 'rejected' || entry.clientMessageId === held) { + // Its own Retry is the step, so the words leave out sending again. + const retryControl = held === null || entry.clientMessageId === held + const text = deliveryNoticeText( + entry, + { agentName, retryControl }, + rejected.get(entry.clientMessageId), + startFailures + ) + notices.set( + agentJournalSubmissionKey(entry.clientMessageId), + retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } + ) + } + } + return notices +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts index da60412183f..9b8b3c537a6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.test.ts @@ -115,7 +115,18 @@ describe("a saved message's last failure", () => { it.each([ { kind: 'refused', code: 'agent_session_checkpoint_stale' }, + { kind: 'refused', code: 'agent_session_conflict', details: { reason: 'chatStarting' } }, + { + kind: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven', ownerVerdict: 'unverifiable' } + }, { kind: 'rejected', reason: 'Claude messages support at most 20 images' }, + { + kind: 'rejected', + reason: 'Claude accepts at most 20 images in one message, so this message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } } + }, { kind: 'rejected', reason: null }, { kind: 'failed' } ])('reads back $kind as it was saved', (lastFailure) => { @@ -137,4 +148,61 @@ describe("a saved message's last failure", () => { expect(read.map((saved) => saved.clientMessageId)).toEqual(['client-1', 'client-2']) expect(read.every((saved) => saved.lastFailure === undefined)).toBe(true) }) + + // Written by a build that kept only the code or the reason; it reads as that build meant it. + it('reads an entry saved before failures carried details as it was', () => { + localStorage.setItem( + 'orca:desktopStructuredAgentSessionOutbox:v1:session-a', + JSON.stringify([ + { + ...entry('session-a', 'client-1'), + lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' } + }, + { ...entry('session-a', 'client-2'), lastFailure: { kind: 'rejected', reason: 'Nope.' } } + ]) + ) + + expect(readOutbox('session-a').map((saved) => saved.lastFailure)).toEqual([ + { kind: 'refused', code: 'agent_session_checkpoint_stale' }, + { kind: 'rejected', reason: 'Nope.' } + ]) + }) + + it('keeps only what a newer build saved that this one can place', () => { + localStorage.setItem( + 'orca:desktopStructuredAgentSessionOutbox:v1:session-a', + JSON.stringify([ + { + ...entry('session-a', 'client-1'), + lastFailure: { + kind: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fromTheFuture', currentFence: 4 } + } + }, + { + ...entry('session-a', 'client-2'), + lastFailure: { + kind: 'rejected', + reason: 'Claude stopped before it finished starting.', + rejection: { kind: 'providerStartFailed', detail: { text: 'exit 1', audience: 'log' } } + } + }, + { + ...entry('session-a', 'client-3'), + lastFailure: { kind: 'rejected', reason: 'Nope.', rejection: { kind: 'fromTheFuture' } } + } + ]) + ) + + expect(readOutbox('session-a').map((saved) => saved.lastFailure)).toEqual([ + { kind: 'refused', code: 'agent_session_checkpoint_stale' }, + { + kind: 'rejected', + reason: 'Claude stopped before it finished starting.', + rejection: { kind: 'providerStartFailed' } + }, + { kind: 'rejected', reason: 'Nope.' } + ]) + }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts new file mode 100644 index 00000000000..8dee55de82e --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-failure-notice.ts @@ -0,0 +1,32 @@ +// What the structured chat says when reading its history failed: the notice table's words for the +// refusal the read met, never the host's message or code. + +import { + agentSessionReadHistoryRefusalParts, + agentSessionWriteNotDoneParts +} from '../../../../shared/agent-session-refusal-notice' +import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' +import { isFinalAgentSessionReadRefusal } from '../../../../shared/structured-agent-session-read-refusal' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' + +export type StructuredAgentSessionReadFailureNotice = { + text: string + /** The host named the situation, so the words say more than that the history did not load. */ + named: boolean + /** Nothing the read retries gets past it, so the pane no longer says it keeps trying. */ + final: boolean +} + +export function structuredAgentSessionReadFailureNotice( + refusal: AgentSessionRefusalReference | undefined +): StructuredAgentSessionReadFailureNotice { + // The pane reconnects on its own, so it is the Retry beside the words. + const parts = refusal + ? agentSessionReadHistoryRefusalParts(refusal.code, refusal.details, { retryControl: true }) + : agentSessionWriteNotDoneParts('read-history') + return { + text: agentSessionWriteNoticeText(parts), + named: refusal?.details?.reason !== undefined, + final: isFinalAgentSessionReadRefusal(refusal) + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index 6ec7b38f8b2..e3f87a8e95e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -223,7 +223,7 @@ function createReadOwner( } const transport = startStructuredAgentSessionReadTransport({ applyEvent: (event) => apply({ type: 'event', event }), - applyError: (message) => apply({ type: 'error', message }), + applyError: (message, refusal) => apply({ type: 'error', message, refusal }), getCursor: () => snapshot.state.cursor, onHistoryReadInvalidated: invalidateOlderPages, hydrate: snapshot.state.epoch === null ? hydrate : undefined, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index 5e5be1b6b3b..683676c99d6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -9,12 +9,16 @@ import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' -const mocks = vi.hoisted(() => ({ subscribe: vi.fn() })) +const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), watchHostContact: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ subscribeStructuredAgentSession: mocks.subscribe })) +vi.mock('@/runtime/runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: mocks.watchHostContact +})) + import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' type SubscribeAttempt = { @@ -269,7 +273,7 @@ describe('structured agent-session read transport unattached refusals', () => { // ...and the failure is owed once it has passed. await vi.advanceTimersByTimeAsync(750) attempts.at(-1)?.onError(rpcRefusal(UNATTACHED)) - expect(applyError).toHaveBeenCalledWith(`RuntimeRpcCallError: ${UNATTACHED}`) + expect(applyError).toHaveBeenCalledWith(UNATTACHED) transport.dispose() } finally { vi.useRealTimers() @@ -284,7 +288,7 @@ describe('structured agent-session read transport unattached refusals', () => { throw new Error('journal read failed') }, applyError) await flushPromises() - expect(applyError).toHaveBeenCalledExactlyOnceWith('Error: journal read failed') + expect(applyError).toHaveBeenCalledExactlyOnceWith('journal read failed') transport.dispose() } finally { vi.useRealTimers() @@ -303,7 +307,107 @@ describe('structured agent-session read transport unattached refusals', () => { expect(applyError).not.toHaveBeenCalled() attempts[0].onError({ code: 'runtime_error', message: 'transport died' }) - expect(applyError).toHaveBeenCalledOnce() + // The host's own words, never `[object Object]` (P2-04). + expect(applyError).toHaveBeenCalledExactlyOnceWith('transport died') + transport.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('hands the pane the refusal a read met, and stops reconnecting only past damage', async () => { + vi.useFakeTimers() + try { + const journalRefusal = (reason: string) => ({ + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } + }) + const applyError = vi.fn() + const transport = startWithHydration(async () => undefined, applyError) + await flushPromises() + expect(attempts).toHaveLength(1) + + // An open that can clear keeps reconnecting. + attempts[0].onError(journalRefusal('journalUnavailable')) + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + }) + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(2) + + // Damage no retry reads past: decided from the reason, not the message, which is the same. + attempts[1].onError(journalRefusal('journalCorrupt')) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + }) + await vi.advanceTimersByTimeAsync(60_000) + expect(attempts).toHaveLength(2) + transport.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('reads a thrown hydrate refusal the same way, and a new run reads again', async () => { + vi.useFakeTimers() + try { + const corrupt = Object.assign(new Error('agent_session_journal_unreadable'), { + response: { + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + } + } + }) + const hydrate = vi.fn(async () => { + throw corrupt + }) + const first = startWithHydration(hydrate, vi.fn()) + await vi.advanceTimersByTimeAsync(60_000) + expect(attempts).toHaveLength(0) + first.dispose() + + // Reopening the chat is a new run, which reads again. + const second = startWithHydration(hydrate, vi.fn()) + await flushPromises() + expect(hydrate).toHaveBeenCalledTimes(2) + second.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('re-opens after a failed open and leaves the error once the conversation reads (P2-04)', async () => { + vi.useFakeTimers() + try { + const applyError = vi.fn() + const applyEvent = vi.fn() + const transport = startWithHydration(async () => undefined, applyError, applyEvent) + await flushPromises() + attempts[0].onError({ code: 'agent_session_journal_unreadable', message: 'disk full' }) + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenCalledExactlyOnceWith('disk full') + + // The retry re-asks, and the host's open runs again: a fault that cleared now reads. + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(2) + attempts[1].onEvent(snapshot(1)) + await flushPromises() + expect(applyEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'snapshot' })) transport.dispose() } finally { vi.useRealTimers() @@ -331,4 +435,128 @@ describe('structured agent-session read transport unattached refusals', () => { vi.useRealTimers() } }) + + it('waits twice as long after each failed open, up to 30 s, and starts over once a read lands', async () => { + vi.useFakeTimers() + try { + const unavailable = { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + } + } + const transport = startWithHydration(async () => undefined, vi.fn()) + await flushPromises() + // As a local subscribe does: the open resolves, then the host's refusal arrives. + const refuseLatest = async (): Promise => { + const attempt = attempts.at(-1)! + attempt.closed.resolve({ unsubscribe: attempt.unsubscribe }) + await flushPromises() + attempt.onError(unavailable) + } + const expectReopenAfter = async (delay: number): Promise => { + const opened = attempts.length + await vi.advanceTimersByTimeAsync(delay - 1) + expect(attempts).toHaveLength(opened) + await vi.advanceTimersByTimeAsync(1) + expect(attempts).toHaveLength(opened + 1) + } + + for (const delay of [750, 1_500, 3_000, 6_000, 12_000, 24_000, 30_000, 30_000]) { + await refuseLatest() + await expectReopenAfter(delay) + } + + // A read that delivers is the success the next failure starts over from. + attempts.at(-1)!.closed.resolve({ unsubscribe: attempts.at(-1)!.unsubscribe }) + await flushPromises() + attempts.at(-1)!.onEvent(snapshot(1)) + attempts.at(-1)!.onError(unavailable) + await expectReopenAfter(750) + await refuseLatest() + await expectReopenAfter(1_500) + + // Damage still ends reconnecting, however far the wait has grown. + const latest = attempts.at(-1)! + latest.closed.resolve({ unsubscribe: latest.unsubscribe }) + await flushPromises() + latest.onError({ + ...unavailable, + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + }) + const opened = attempts.length + await vi.advanceTimersByTimeAsync(120_000) + expect(attempts).toHaveLength(opened) + transport.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('reads again as soon as a remote host is reachable, not after the grown wait', async () => { + vi.useFakeTimers() + try { + let hostContactRegained = (): void => {} + const stopWatch = vi.fn() + mocks.watchHostContact.mockImplementation((_environmentId, listener: () => void) => { + hostContactRegained = listener + return stopWatch + }) + const unavailable = { code: 'runtime_unavailable', message: 'Remote runtime is unavailable.' } + const transport = startStructuredAgentSessionReadTransport({ + applyEvent: vi.fn(), + applyError: vi.fn(), + getCursor: () => null, + onHistoryReadInvalidated: () => undefined, + hydrate: async () => undefined, + sessionId: 'session-a', + target: { kind: 'environment', environmentId: 'env-a' } + }) + expect(mocks.watchHostContact).toHaveBeenCalledWith('env-a', expect.any(Function)) + await flushPromises() + const refuseLatest = async (): Promise => { + const attempt = attempts.at(-1)! + attempt.closed.resolve({ unsubscribe: attempt.unsubscribe }) + await flushPromises() + attempt.onError(unavailable) + } + // An outage long enough that the wait reaches its cap. + for (const delay of [750, 1_500, 3_000, 6_000, 12_000, 24_000]) { + await refuseLatest() + await vi.advanceTimersByTimeAsync(delay) + } + await refuseLatest() + const opened = attempts.length + await vi.advanceTimersByTimeAsync(29_000) + expect(attempts).toHaveLength(opened) + + hostContactRegained() + await vi.advanceTimersByTimeAsync(0) + expect(attempts).toHaveLength(opened + 1) + + // The wait starts over too, so a failure right after reconnect is retried soon. + await refuseLatest() + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(opened + 2) + + transport.dispose() + expect(stopWatch).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('does not watch host contact for a local read', () => { + startWithHydration(async () => undefined, vi.fn()).dispose() + expect(mocks.watchHostContact).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts index f4310cec890..a0a693e92a9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -3,18 +3,48 @@ import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-sessio import { createStructuredAgentSessionEventCoalescer } from '../../../../shared/structured-agent-session-coalescer' import { AGENT_SESSION_UNATTACHED_READ_GRACE_MS, + isFinalAgentSessionReadRefusal, isUnattachedAgentSessionReadRefusal } from '../../../../shared/structured-agent-session-read-refusal' +import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' +import { readAgentSessionErrorRefusal } from '../../../../shared/agent-session-write-failure' +import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' +// A stream hands its failure over as the raw `{ code, message }` payload; `String()` of that is `[object Object]`. +function readFailureText(error: unknown): string { + if (error instanceof Error) { + return error.message || 'Something went wrong.' + } + if (typeof error === 'string') { + return error || 'Something went wrong.' + } + if (typeof error === 'object' && error !== null) { + if ('message' in error && typeof error.message === 'string' && error.message.length > 0) { + return error.message + } + if ('code' in error && typeof error.code === 'string' && error.code.length > 0) { + return error.code + } + } + return 'Something went wrong.' +} + +const RECONNECT_FIRST_DELAY_MS = 750 +const RECONNECT_MAX_DELAY_MS = 30_000 + +/** Each reconnect waits twice the last, up to the cap, until a read delivers. */ function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: () => void }) { let timer: ReturnType | null = null + let nextDelay = RECONNECT_FIRST_DELAY_MS return { - schedule(delay = 750): void { + schedule(): void { if (args.shouldStop() || timer) { return } + const delay = nextDelay + nextDelay = Math.min(delay * 2, RECONNECT_MAX_DELAY_MS) timer = setTimeout(() => { timer = null if (!args.shouldStop()) { @@ -22,6 +52,22 @@ function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: } }, delay) }, + /** A read delivered, so the next failure is retried soon again. */ + reset(): void { + nextDelay = RECONNECT_FIRST_DELAY_MS + }, + /** The host is reachable again: a waiting retry runs now instead of after the grown wait. */ + retryNow(): void { + nextDelay = RECONNECT_FIRST_DELAY_MS + if (!timer) { + return + } + clearTimeout(timer) + timer = null + if (!args.shouldStop()) { + args.reconnect() + } + }, dispose(): void { if (timer) { clearTimeout(timer) @@ -33,7 +79,8 @@ function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: export function startStructuredAgentSessionReadTransport(args: { applyEvent: (event: AgentSessionSubscribeEvent) => void - applyError: (message: string) => void + /** `message` is the failure's own text, for logs; `refusal` is what a surface words. */ + applyError: (message: string, refusal?: AgentSessionRefusalReference) => void getCursor: () => AgentJournalCursor | null onHistoryReadInvalidated: () => void hydrate?: (shouldStop: () => boolean) => Promise @@ -45,6 +92,8 @@ export function startStructuredAgentSessionReadTransport(args: { } { let stopped = false let connected = false + // A refusal no retry reads past ends reconnecting for this run; reopening the chat starts another. + let failedFinally = false let unattachedSince: number | null = null let opening = false let openGeneration = 0 @@ -57,9 +106,16 @@ export function startStructuredAgentSessionReadTransport(args: { } }) const reconnectScheduler = createReconnectScheduler({ - shouldStop: () => stopped || connected, + shouldStop: () => stopped || connected || failedFinally, reconnect: () => void open() }) + // Why: an outage grows the retry wait to its cap; a host that is back should not wait it out. + const stopHostContactWatch = + args.target.kind === 'environment' + ? subscribeRuntimeHostContactRegained(args.target.environmentId, () => + reconnectScheduler.retryNow() + ) + : () => {} const isCurrentOpenGeneration = (candidate: number): boolean => !stopped && candidate === openGeneration const clearUnattachedReadGrace = (): void => { @@ -69,23 +125,32 @@ export function startStructuredAgentSessionReadTransport(args: { * A read failure, reported to the pane only once it is one. * * A refusal saying this host holds no attached session is the retry loop's own subject, not a - * verdict: the reconnect below re-asks, and either the hold that attaches the session or the tab - * retirement that ends the pane resolves it. Painting the pane red inside that window turned an + * verdict: the reconnect below re-asks, and either the host opening the session or the tab + * retirement that ends the pane resolves it. Only an older host still refuses this way. Painting the pane red inside that window turned an * ordinary chat close into a `Could not load conversation` the user could do nothing about. * * A window, not a mute. An unattached read still refusing past the grace is no longer * transitional, so the pane is owed the failure rather than a spinner that never resolves. */ + const applyReadFailure = (error: unknown): void => { + const refusal = readAgentSessionErrorRefusal(error) + failedFinally = isFinalAgentSessionReadRefusal(refusal) + if (refusal) { + args.applyError(readFailureText(error), refusal) + } else { + args.applyError(readFailureText(error)) + } + } const reportReadFailure = (error: unknown): void => { if (!isUnattachedAgentSessionReadRefusal(error)) { clearUnattachedReadGrace() - args.applyError(String(error)) + applyReadFailure(error) return } const now = Date.now() unattachedSince ??= now if (now - unattachedSince >= AGENT_SESSION_UNATTACHED_READ_GRACE_MS) { - args.applyError(String(error)) + applyReadFailure(error) } } const captureHistoryReadGuard = (): (() => boolean) => { @@ -99,6 +164,10 @@ export function startStructuredAgentSessionReadTransport(args: { return } clearUnattachedReadGrace() + // Not on connect: a local subscribe resolves before the host's open refuses. + if (event.type !== 'end') { + reconnectScheduler.reset() + } if (event.type === 'snapshot' || event.type === 'reset') { coalescer.flush() if (!isCurrentOpenGeneration(eventOpenGeneration)) { @@ -210,6 +279,7 @@ export function startStructuredAgentSessionReadTransport(args: { stopped = true openGeneration += 1 args.onHistoryReadInvalidated() + stopHostContactWatch() reconnectScheduler.dispose() coalescer.dispose() unsubscribe() diff --git a/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts new file mode 100644 index 00000000000..ea9b3a0cf3b --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { projectAgentSessionConversationOutline } from '../../../../shared/agent-session-conversation-outline' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +function journalItem( + itemId: string, + sequence: number, + observedAt: number, + body: AgentJournalItemBody, + extra: Partial = {} +): AgentJournalRenderItem { + return { itemId, revision: 1, sequence, observedAt, body, ...extra } +} + +function said(role: 'user' | 'assistant', text: string): AgentJournalItemBody { + return { kind: 'message', role, blocks: [{ type: 'text', text }] } +} + +function answered(question: string): AgentJournalItemBody { + return { + kind: 'question', + question, + options: [{ id: 'yes', label: 'Yes' }], + resolution: { state: 'resolved', selectedOptionId: 'yes', resolvedBy: 'client', resolvedAt: 9 } + } +} + +/** The ids the desktop transcript list draws, top to bottom. */ +function drawn( + items: AgentJournalRenderItem[], + outbox: StructuredAgentSessionOutboxEntry[] = [], + submissions: AgentJournalSubmission[] = [] +): string[] { + return createNativeChatMessageListProjection()( + projectStructuredAgentSessionMessages(items, outbox, submissions) + ).map(({ id }) => id) +} + +function queued(clientMessageId: string, text: string, queuedAt: number) { + return createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session', + text, + attachments: [], + queuedAt + }) +} + +describe('structured transcript order', () => { + it('draws a row recovered after a crash at its journal position, not its earlier clock', () => { + // The provider logged the last prompt before the crash; the host journalled it on recovery. + const items = [ + journalItem('ask', 1, 100, said('user', 'Fix the parser')), + journalItem('reply', 2, 300, said('assistant', 'Working on it')), + journalItem('steer', 3, 310, said('user', 'Keep the old API')), + journalItem('recovered', 4, 200, said('user', 'Also add a test'), { + recovered: true, + recoveredAt: 400 + }) + ] + expect(drawn(items)).toEqual(['ask', 'reply', 'steer', 'recovered']) + // The host's outline, published to every client, uses the same order. + expect(projectAgentSessionConversationOutline(items, []).map(({ itemId }) => itemId)).toEqual([ + 'ask', + 'steer', + 'recovered' + ]) + }) + + it("draws one write's questions by their place in it, whatever order they arrive in", () => { + // One Codex ask: a single journal write, one sequence and one timestamp. Neither + // the ids nor the arrival order below spell the order it was asked in. + const scope = journalItem('q:scope', 5, 500, answered('Which files?')) + const priority = journalItem('q:priority', 5, 500, answered('What matters?'), { + sequenceIndex: 1 + }) + const deadline = journalItem('q:deadline', 5, 500, answered('When?'), { sequenceIndex: 2 }) + expect(drawn([priority, deadline, scope])).toEqual(['q:scope', 'q:priority', 'q:deadline']) + }) + + it('keeps a send the journal does not hold yet below every row it does', () => { + // The composer's clock can trail the host's; the unsent message still reads last. + const outbox = [queued('queued', 'One more thing', 150)] + const items = [ + journalItem('ask', 1, 100, said('user', 'Fix the parser')), + journalItem('reply', 2, 200, said('assistant', 'Working on it')) + ] + expect(drawn(items, outbox)).toEqual(['ask', 'reply', agentJournalSubmissionKey('queued')]) + }) + + it('keeps a send the journal recorded and the provider refused at its journal place', () => { + // The agent kept writing after the refused steer; its Retry stays with the composer. + const refused = agentJournalSubmissionKey('steer') + const items = [ + journalItem('ask', 1, 100, said('user', 'Fix the parser')), + journalItem(refused, 2, 200, said('user', 'Keep the old API')), + journalItem('reply', 3, 300, said('assistant', 'Done.')) + ] + const submissions: AgentJournalSubmission[] = [ + { + clientMessageId: 'steer', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_refused', + submittedAt: 200, + resolvedAt: 250 + } + ] + expect( + drawn( + items, + [queued('steer', 'Keep the old API', 190), queued('next', 'And docs', 400)], + submissions + ) + ).toEqual(['ask', refused, 'reply', agentJournalSubmissionKey('next')]) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts index 71a654c0927..f59d86303f1 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-interrupt.ts @@ -25,7 +25,7 @@ export function useNativeChatComposerInterrupt(args: { } const target = resolveTarget() if (target) { - sendRuntimePtyInput(target.settings, target.ptyId, ESC) + sendRuntimePtyInput(target.settings, target.ptyId, ESC, 'driving') } }, [cancelPendingSends, isWorking, onStop, resolveTarget]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 864c62f6f91..f2cbd8df93c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -195,7 +195,8 @@ describe('useNativeChatInteractiveSend', () => { expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith( { terminalTabId: 'tab-1' }, 'pty-1', - '\x1b' + '\x1b', + 'driving' ) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 43adabae8f3..3d270cde49b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -76,7 +76,12 @@ export function useNativeChatInteractiveSend( if (!targetPtyId) { return } - sendRuntimePtyInput(getSettingsForAgentTabRuntimeOwner(terminalTabId), targetPtyId, raw) + sendRuntimePtyInput( + getSettingsForAgentTabRuntimeOwner(terminalTabId), + targetPtyId, + raw, + 'driving' + ) }, [terminalTabId, targetPtyId] ) diff --git a/src/renderer/src/components/native-chat/use-native-chat-launch-prompt-delivery-notice.ts b/src/renderer/src/components/native-chat/use-native-chat-launch-prompt-delivery-notice.ts new file mode 100644 index 00000000000..c3d7974c160 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-launch-prompt-delivery-notice.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react' +import { translate } from '@/i18n/i18n' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' + +/** The terminal-backed chat's one delivery notice: its launch prompt, when the terminal refused it + * and the prompt's row is on screen. The terminal is where to act, so it offers no Retry. */ +export function useNativeChatLaunchPromptDeliveryNotice( + failedMessageId: string | null | undefined, + messages: readonly NativeChatMessage[] +): ReadonlyMap | undefined { + return useMemo(() => { + if (!failedMessageId || !messages.some((message) => message.id === failedMessageId)) { + return undefined + } + const text = translate( + 'components.native-chat.launchPromptNotDelivered', + 'Not delivered — check the terminal' + ) + return new Map([[failedMessageId, { text }]]) + }, [failedMessageId, messages]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 0661eb0df88..12528859d71 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -3,7 +3,7 @@ import { getStructuredAgentSessionLaunchLifecycle, getStructuredAgentSessionLaunchResumes, retryStructuredAgentSessionLaunch, - useStructuredAgentSessionLaunchFailureReason, + useStructuredAgentSessionLaunchFailure, useStructuredAgentSessionLaunchLifecycle, useStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch' @@ -66,7 +66,7 @@ export function useNativeChatProvisionalLaunch( sessionId: string ) { const lifecycle = useStructuredAgentSessionLaunchLifecycle(worktreeId ?? '', sessionId) - const failureReason = useStructuredAgentSessionLaunchFailureReason(worktreeId ?? '', sessionId) + const failure = useStructuredAgentSessionLaunchFailure(worktreeId ?? '', sessionId) const launch = useLatchedLaunchView(sessionId, worktreeId, lifecycle !== null) const retry = useCallback(() => { if (worktreeId) { @@ -91,7 +91,7 @@ export function useNativeChatProvisionalLaunch( return { lifecycle, launch, - failureReason, + failure, retry, sendThroughRelaunch, transportEnabled: lifecycle === null || lifecycle === 'published' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts index 628a369281b..f484f500a56 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts @@ -8,6 +8,9 @@ // // The release CHAINS off the hold rather than racing it: an unmount during the hold's round trip // would otherwise release a hold that has not landed yet, and the late hold would never be undone. +// +// A current host ignores both: it starts an agent only for a send. They stay for older hosts, until +// MIN_COMPATIBLE_RUNTIME_SERVER_VERSION passes the first host that ignores them. import { useEffect, useRef } from 'react' import { structuredAgentSessionHolderId } from '../../../../shared/structured-agent-session-holder' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 5d20ce6a110..40979780b36 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -12,9 +12,9 @@ import * as conversationCommands from './structured-conversation-command-send' import type { AgentSessionMutationResult } from '../../../../shared/agent-session-wire' import { agentSessionRefusalFailure, - agentSessionRpcErrorFailure, + agentSessionThrownFailure, agentSessionWriteKindForMethod as writeKind -} from '../../../../shared/agent-session-refusal-notice' +} from '../../../../shared/agent-session-write-failure' import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' @@ -98,7 +98,8 @@ export function useStructuredAgentSessionMutate(args: { ? { kind: 'not-done', notice: agentSessionWriteFailureText( - agentSessionRpcErrorFailure( + agentSessionThrownFailure( + error, error instanceof RuntimeRpcCallError ? error.code : undefined ), writeKind(fingerprintMethod, fields) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx index 0fff127cd2c..feb9f898ce2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx @@ -21,6 +21,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage' import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' const LOCAL_TARGET = { kind: 'local' } as const @@ -32,12 +34,19 @@ function deferred() { return { promise, resolve } } -type SendRequest = { body?: { blocks?: { text?: string }[] } } +type SendRequest = { + body?: { blocks?: { text?: string }[] } + envelope?: { clientOperationId?: string } +} function requestText(params: SendRequest | undefined): string | undefined { return params?.body?.blocks?.[0]?.text } +function requestId(params: SendRequest | undefined): string { + return String(params?.envelope?.clientOperationId) +} + function sentTexts(): (string | undefined)[] { return mocks.call.mock.calls.map((call) => requestText(call[2])) } @@ -219,4 +228,94 @@ describe('structured agent session outbox admission', () => { await act(async () => admission.resolve(submissionResult(String(id), 'pending', 10))) expect(sentTexts()).toEqual(['first', 'second']) }) + + // A message the queue is held on goes out only by its own Retry, never another message's. + it("keeps a refused message held when a rejected message's Retry shows while a head waits", async () => { + const inFlight = deferred>() + mocks.call.mockImplementation((_target, _method, params) => { + const text = requestText(params) + const attempt = sentTexts().filter((sent) => sent === text).length + const id = requestId(params) + if (text === 'held') { + return Promise.resolve(refusedResult('agent_session_ownership_unknown')) + } + if (attempt === 1) { + return Promise.resolve(refusedResult('agent_session_owner_restart_failed')) + } + return text === 'x' ? inFlight.promise : Promise.resolve(submissionResult(id, 'accepted', 20)) + }) + const { result } = renderOutbox() + for (const text of ['x', 'a', 'c']) { + act(() => expect(result.current.send(text)).toBe(true)) + await waitFor(() => expect(result.current.outbox.at(-1)?.state).toBe('rejected')) + } + act(() => expect(result.current.send('held')).toBe(true)) + await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull()) + const heldId = result.current.blockedClientMessageId + const [x, a, c] = result.current.outbox + + // x goes out and stays in flight, so a, retried after it, waits queued ahead of the held one. + act(() => result.current.retry(x!.clientMessageId)) + await waitFor(() => expect(sentTexts()).toEqual(['x', 'a', 'c', 'held', 'x'])) + act(() => result.current.retry(a!.clientMessageId)) + await settleTimers(20) + // The drain would send a next, so the queue reads as moving and c offers its own Retry. + const notices = structuredAgentSessionDeliveryNotices( + result.current.outbox, + result.current.blockedClientMessageId, + 'Claude', + () => {}, + [], + [] + ) + expect(notices.get(agentJournalSubmissionKey(c!.clientMessageId))?.onRetry).toBeDefined() + + act(() => result.current.retry(c!.clientMessageId)) + expect(result.current.blockedClientMessageId).toBe(heldId) + await act(async () => inFlight.resolve(submissionResult(x!.clientMessageId, 'accepted', 20))) + await waitFor(() => expect(result.current.outbox).toHaveLength(1)) + await settleTimers(50) + expect(sentTexts().filter((text) => text === 'held')).toHaveLength(1) + expect(result.current.blockedClientMessageId).toBe(heldId) + }) + + it('keeps a refused message held when an unconfirmed message ahead of it is retried', async () => { + const none: readonly AgentJournalSubmission[] = [] + mocks.call.mockImplementation((_target, _method, params) => { + const id = requestId(params) + if (requestText(params) === 'held') { + return Promise.resolve(refusedResult('agent_session_ownership_unknown')) + } + const sent = sentTexts().filter((text) => text === 'first').length + return Promise.resolve(submissionResult(id, sent === 1 ? 'pending' : 'accepted', 10)) + }) + const { result, rerender } = renderHook( + ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions + }), + { initialProps: { submissions: none } } + ) + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) + act(() => expect(result.current.send('held')).toBe(true)) + await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull()) + const heldId = result.current.blockedClientMessageId + const firstId = String(result.current.outbox[0]?.clientMessageId) + const unknown: AgentJournalSubmission = { + ...submissionResult(firstId, 'pending', 10).value.submission, + dispatchState: 'unknown' + } + rerender({ submissions: [unknown] }) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + + act(() => result.current.retry(firstId)) + await waitFor(() => expect(result.current.outbox).toHaveLength(1)) + await settleTimers(50) + expect(sentTexts()).toEqual(['first', 'held', 'first']) + expect(result.current.blockedClientMessageId).toBe(heldId) + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index a8b9f01b20b..3ce99e6fd4b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -17,7 +17,11 @@ import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-cap import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' -import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { writeOutbox } from './structured-agent-session-outbox-storage' function shownFailure(entry: StructuredAgentSessionOutboxEntry | undefined): string | undefined { return ( @@ -269,6 +273,47 @@ describe('a send the host rejected because the agent never started', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) }) + // After a restart nothing in memory remembers the rejection, and its journal row may be older + // than the loaded page: the message's own state is what says a resend needs a new id. + it('retries a message rejected before a restart under a new id', async () => { + const rejected = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'rejected-before-restart', + sessionId: 'session-1', + text: 'first', + attachments: [], + queuedAt: 1 + }) + writeOutbox('session-1', [ + { + ...rejected, + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected' } + } + } + ]) + mocks.call.mockImplementation(async (_target, _method, params) => + acceptedResultFor(String(params.envelope.clientOperationId)) + ) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [] + }) + ) + expect(result.current.outbox[0]?.state).toBe('rejected') + + act(() => result.current.retry('rejected-before-restart')) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId + expect(sentId).not.toBe('rejected-before-restart') + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + }) + it('keeps the rejection when the journal settles the message before the send answers', async () => { const reason = "Codex couldn't restart: spawn codex ENOENT." let answer: (value: unknown) => void = () => undefined diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 254c936cf63..ae2a1ae0a61 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -52,7 +52,6 @@ export function useStructuredAgentSessionOutbox(args: { const inFlightIdRef = useRef(null) const dispatchGenerationRef = useRef(0) const blockedIdRef = useRef(null) - const retryWithFreshClientMessageIdRef = useRef(null) const probeAttemptsRef = useRef({ id: null as string | null, attempts: 0 }) const [error, setError] = useState(null) const [errorSession, setErrorSession] = useState(sessionId) @@ -71,7 +70,6 @@ export function useStructuredAgentSessionOutbox(args: { dispatchGenerationRef.current += 1 inFlightIdRef.current = null blockedIdRef.current = null - retryWithFreshClientMessageIdRef.current = null probeAttemptsRef.current = { id: null, attempts: 0 } }, [owner.ownerChange, owner.targetKey, sessionId]) @@ -139,7 +137,6 @@ export function useStructuredAgentSessionOutbox(args: { // drain, so a later microtask would leave the queue with no trigger to move on. inFlightIdRef.current = null blockedIdRef.current = disposition.blockedClientMessageId - retryWithFreshClientMessageIdRef.current = disposition.retryWithFreshClientMessageId setError(disposition.error) outboxRef.current = disposition.entries setOutbox(disposition.entries) @@ -286,21 +283,22 @@ export function useStructuredAgentSessionOutbox(args: { ) const retry = (clientMessageId: string): void => { - blockedIdRef.current = null + // Another message's Retry must not send the one the queue is held on. + if (blockedIdRef.current === clientMessageId) { + blockedIdRef.current = null + } setError(null) const submission = submissions.find( (candidate) => candidate.clientMessageId === clientMessageId ) const current = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId) - // A provider-history reconciliation can settle an earlier unknown as - // rejected before the user presses Retry. Reusing that operation id only - // replays the settled rejection forever, so rotate the id for a safe resend. - if ( - current && - (submission?.dispatchState === 'rejected' || - retryWithFreshClientMessageIdRef.current === clientMessageId) - ) { - retryWithFreshClientMessageIdRef.current = null + // The host settled this id as rejected, and reusing it only replays that forever, so rotate the + // id for a safe resend. Read from the message itself, which outlives a restart, or from a + // reconciliation that settled an earlier unknown before the outbox caught up. A refusal that + // settled the message already rotated it. + const recordedRejection = + current?.state === 'rejected' && current.lastFailure?.kind === 'rejected' + if (current && (recordedRejection || submission?.dispatchState === 'rejected')) { const rotated = outboxRef.current.map((entry) => entry.clientMessageId === clientMessageId ? { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts new file mode 100644 index 00000000000..97ef023dffb --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { expect, it } from 'vitest' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' + +function startFailureRow(startKey: string, fact: AgentSessionFailureFact): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)), + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + } + } +} + +// A streaming turn hands over a new item list on every delta; the facts must not follow it. +it('holds the same facts while the start rows state nothing new', () => { + const row = startFailureRow('gen-1', { kind: 'providerStartFailed' }) + const { result, rerender } = renderHook( + ({ items }) => useStructuredAgentSessionStartFailureFacts(items, true), + { initialProps: { items: [row] } } + ) + const first = result.current + expect(first).toEqual([{ kind: 'providerStartFailed' }]) + + rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) + expect(result.current).not.toBe(first) + const second = result.current + + rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) + expect(result.current).toBe(second) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts new file mode 100644 index 00000000000..83f20103de1 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts @@ -0,0 +1,33 @@ +import { useEffect, useMemo, useRef } from 'react' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { + sameAgentSessionFailureFact, + structuredAgentSessionStartFailureFacts +} from './structured-agent-session-delivery-notices' + +const NO_FACTS: readonly AgentSessionFailureFact[] = [] + +/** What the loaded start-failure rows state, read only while `enabled`. Held while unchanged, so a + * streaming turn does not rebuild every row's delivery notice. */ +export function useStructuredAgentSessionStartFailureFacts( + items: readonly AgentJournalRenderItem[], + enabled: boolean +): readonly AgentSessionFailureFact[] { + const facts = useMemo( + () => (enabled ? structuredAgentSessionStartFailureFacts(items) : NO_FACTS), + [enabled, items] + ) + const previousRef = useRef(NO_FACTS) + const previous = previousRef.current + const stable = + previous.length === facts.length && + previous.every((fact, index) => sameAgentSessionFailureFact(fact, facts[index])) + ? previous + : facts + // Written after commit, so render stays pure. + useEffect(() => { + previousRef.current = stable + }, [stable]) + return stable +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts index 7bf979f8ff0..f0c3eaa59a8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport.ts @@ -20,10 +20,8 @@ export function useStructuredAgentSessionTransport(args: { enabled: providerVisible }) // A worktree switch hides the pane, but a message the user already sent is still owed a - // delivery. The read is what carries the journal rows that retire it, and its subscription - // takes a retaining hold — without which the host evicts the session once its idle window runs - // out and then refuses the send outright. Gated on `enabled`: a session not yet published has - // nothing to read. Attention is not the signal; owed work is. + // delivery, and the read is what carries the journal rows that retire it. Gated on `enabled`: + // a session not yet published has nothing to read. Attention is not the signal; owed work is. const hasUndelivered = useUndeliveredStructuredAgentSessionOutbox(sessionId) const read = useStructuredAgentSessionRead({ sessionId, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx index ac09dde1da8..8cc467a3d28 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx @@ -115,6 +115,46 @@ describe('a chat write the host refused', () => { ) }) + it('words a refusal the host threw from its data, never the bare code it carries as its message', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.reject( + new RuntimeRpcCallError({ + id: 'request-1', + ok: false, + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await expect(result.current.cancel('turn-1')).resolves.toBeNull() + }) + + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + "Orca couldn't open this chat's history right now. The agent wasn't stopped. Try again." + ) + }) + it('answers a refused conversation command inline, where the command was typed', async () => { mocks.call.mockImplementation((_target, method) => method === 'agentSession.options' @@ -147,4 +187,47 @@ describe('a chat write the host refused', () => { expect(mocks.toastError).not.toHaveBeenCalled() expect(result.current.error).toBeNull() }) + + it('says the reason the host named, for a Stop and for a command', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.resolve({ + ok: false, + refusal: + method === 'agentSession.cancel' + ? { + code: 'agent_session_conflict', + message: 'The chat is still starting.', + details: { reason: 'chatStarting' } + } + : { + code: 'agent_session_operation_invalid', + message: 'Wait for the current turn to finish before using this command.', + details: { reason: 'turnActive' } + } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await expect(result.current.cancel('turn-1')).resolves.toBeNull() + await expect(result.current.runConversationCommand('compact')).resolves.toEqual({ + accepted: false, + error: + "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it." + }) + }) + + expect(mocks.toastError).toHaveBeenCalledWith( + "The agent is still starting. The agent wasn't stopped. Wait for the agent to finish starting." + ) + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index e45699e830c..659bbafbbd9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -136,7 +136,10 @@ export function useStructuredAgentSession(args: { journalItems: transportState.journalItems, messages, status: transportEnabled ? state.status : 'ready', - error: transportEnabled ? (state.error ?? outboxController.error) : outboxController.error, + /** The outbox's own line; a failed read is worded from `readRefusal`, never its text. */ + error: outboxController.error, + /** The refusal the failed read met, while `status` is `error`. */ + readRefusal: transportEnabled ? state.readRefusal : undefined, hasOlder: transportEnabled && state.hasOlder, railOutline: transportEnabled ? railOutline : null, loadingOlder: transportEnabled && loadingOlder, @@ -144,6 +147,8 @@ export function useStructuredAgentSession(args: { loadOlder, prompts, outbox, + /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ + submissions: transportState.submissions, blockedClientMessageId: outboxController.blockedClientMessageId, send: (...input: Parameters) => !commandPending.current && outboxController.send(...input), diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts index 3518bcba8aa..bc8596d564d 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts @@ -31,6 +31,16 @@ describe('getStatusBarToggles', () => { ) }) + it('includes ZCode quota in Appearance search', () => { + const toggle = getStatusBarToggles().find((entry) => entry.id === 'zcode') + + expect(toggle).toMatchObject({ + title: 'ZCode Usage', + toggleDescription: 'Show ZCode Coding Plan quota usage.' + }) + expect(toggle?.keywords).toEqual(expect.arrayContaining(['zcode', 'zai', 'glm'])) + }) + it('includes MiniMax usage so Appearance can toggle the default-on status item', () => { const miniMaxToggle = getStatusBarToggles().find((entry) => entry.id === 'minimax') diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 8a4d65f21fd..16749f4ab31 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -6,6 +6,7 @@ import { getAntigravityStatusBarToggleSearchEntry } from './appearance-status-ba import { getMiniMaxStatusBarToggleSearchEntry } from './appearance-status-bar-minimax-toggle-search' import { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search' import { getCursorStatusBarToggleSearchEntry } from './appearance-status-bar-cursor-toggle-search' +import { getZcodeStatusBarToggleSearchEntry } from './appearance-status-bar-zcode-toggle-search' export const getStatusBarToggles = createLocalizedCatalog( (): readonly { @@ -169,6 +170,7 @@ export const getStatusBarToggles = createLocalizedCatalog( getMiniMaxStatusBarToggleSearchEntry(), getGrokStatusBarToggleSearchEntry(), getCursorStatusBarToggleSearchEntry(), + getZcodeStatusBarToggleSearchEntry(), { id: 'ssh', title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), diff --git a/src/renderer/src/components/settings/appearance-status-bar-zcode-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-zcode-toggle-search.ts new file mode 100644 index 00000000000..1d618a65dc3 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-zcode-toggle-search.ts @@ -0,0 +1,34 @@ +import type { StatusBarItem } from '../../../../shared/ui-chrome-types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function getZcodeStatusBarToggleSearchEntry(): { + id: StatusBarItem + title: string + description: string + keywords: string[] + toggleDescription: string +} { + return { + id: 'zcode', + title: translate('auto.components.settings.appearance.search.zcodeUsageTitle', 'ZCode Usage'), + description: translate( + 'auto.components.settings.appearance.search.zcodeUsageDescription', + 'Show ZCode Coding Plan quota usage in the status bar.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.zcode', 'zcode'), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword('auto.components.settings.appearance.search.zai', 'zai'), + ...translateSearchKeyword('auto.components.settings.appearance.search.glm', 'glm') + ], + toggleDescription: translate( + 'settings.appearance.statusBar.zcodeToggleDescription', + 'Show ZCode Coding Plan quota usage.' + ) + } +} diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index 8d3e03ebeaa..df0cd67b412 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -51,7 +51,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): 'relative flex cursor-pointer flex-col pr-1.5 transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none', titleOnlyCard ? 'py-2' : 'pt-1.25 pb-1.5', flushSurface ? 'ml-1 w-[calc(100%-0.25rem)]' : 'ml-1', - 'rounded-lg', + 'overflow-hidden rounded-lg', // Why: the live data attribute updates before React state during navigation, // so it must own the complete active style without stale utility classes. isLineageDropTarget diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 3a9bc0cba48..f3aaa6cbede 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -6,7 +6,14 @@ import { type UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' -import { ProviderIcon, clampUsedPercent, getProviderUsageStatusLabel } from './tooltip' +import { + ProviderIcon, + USAGE_URGENT_PERCENT, + USAGE_WARNING_PERCENT, + clampUsedPercent, + getProviderDisplayName, + getProviderUsageStatusLabel +} from './tooltip' import { getTightestUsageSection } from './UsageRosterPanel' import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter' import { formatUsagePercentageLabel } from './usage-percentage-label' @@ -67,6 +74,65 @@ export function ProviderLetterBadge({ p }: { p: ProviderRateLimits }): React.JSX ) } +export type UsageTone = 'urgent' | 'warning' | 'normal' + +/** Urgency by consumption, matching the usage bar colors, whatever % display the user chose. */ +export function getUsageTone(p: ProviderRateLimits): UsageTone { + const tightest = getTightestUsageSection(p) + const used = tightest ? clampUsedPercent(tightest.window.usedPercent) : 0 + return used >= USAGE_URGENT_PERCENT + ? 'urgent' + : used >= USAGE_WARNING_PERCENT + ? 'warning' + : 'normal' +} + +/** + * Stands in for usage chips a narrow bar can't fit. Always rendered at the collapsing + * density so its width is known before anything collapses; out of the row while empty. + */ +export function UsageOverflowChip({ + hidden, + display +}: { + hidden: readonly ProviderRateLimits[] + display: UsagePercentageDisplay +}): React.JSX.Element { + const tones = hidden.map(getUsageTone) + const tone = tones.includes('urgent') + ? 'urgent' + : tones.includes('warning') + ? 'warning' + : 'normal' + const names = hidden + .map((p) => { + const tightest = getTightestUsageSection(p) + const name = getProviderDisplayName(p.provider) + return tightest + ? `${name} ${formatUsagePercentageLabel(tightest.window.usedPercent, display)}` + : name + }) + .join(', ') + return ( + + +{Math.max(1, hidden.length)} + + ) +} + function getProviderLetter(provider: ProviderRateLimits['provider']): string { switch (provider) { case 'claude': @@ -85,6 +151,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { return 'R' case 'cursor': return 'U' + case 'zcode': + return 'Z' case 'codex': return 'X' } diff --git a/src/renderer/src/components/status-bar/StatusBarSurface.tsx b/src/renderer/src/components/status-bar/StatusBarSurface.tsx index dfdcbb926db..844eeabe504 100644 --- a/src/renderer/src/components/status-bar/StatusBarSurface.tsx +++ b/src/renderer/src/components/status-bar/StatusBarSurface.tsx @@ -8,7 +8,6 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' -import { getProviderDisplayName } from './tooltip' import { UsageRosterPanel } from './UsageRosterPanel' import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' import { @@ -27,7 +26,7 @@ import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/ import { ClaudeSwitcherMenu } from './ClaudeSwitcherMenu' import { CodexSwitcherMenu } from './CodexSwitcherMenu' import { ProviderDetailsMenu, CLOSE_ALL_CONTEXT_MENUS_EVENT } from './ProviderDetailsMenu' -import { ProviderLetterBadge, ProviderSegment } from './StatusBarProviderSegment' +import { ProviderSegment, UsageOverflowChip, getUsageTone } from './StatusBarProviderSegment' import { useStatusBarController } from './use-status-bar-controller' import { StatusBarVisibilityMenu } from './StatusBarVisibilityMenu' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' @@ -61,8 +60,10 @@ export function StatusBarSurface({ const { anyFetching, anyVisible, + barRef, + collapseUsage, + collapsedUsageProviders, compact, - containerRefCallback, floatingTerminalActionLabel, floatingTerminalShortcut, handleManageAccounts, @@ -71,11 +72,13 @@ export function StatusBarSurface({ handleUsageDetails, handleUsageMenuOpenChange, hasVisibleUsageMeters, - iconOnly, isEmptyUsageState, isRefreshing, + overflowing, petEnabled, rosterProviders, + segmentsIconOnly, + segmentsRef, setMenuOpen, setMenuPoint, setStatusBarUsageMode, @@ -88,13 +91,16 @@ export function StatusBarSurface({ statusBarUsageMode, usageMenuFocusHandoff, usageMenuOpen, - usagePercentageDisplay + usagePercentageDisplay, + usageRef, + usageTightestOnly } = controller return (
{ if (!shouldOpenStatusBarContextMenu(event.target)) { return @@ -107,193 +113,217 @@ export function StatusBarSurface({ setMenuOpen(true) }} > -
- {isEmptyUsageState ? ( - showEmptyUsageCta ? ( - - ) : null - ) : hasVisibleUsageMeters ? ( - // Consolidated roster pill → opens the all-agents Usage popover (mock parity). - - - - - - - getUsageProviderAccountsSectionId(provider) !== null} - onManageAccounts={handleManageAccounts} - onUsageDetails={handleUsageDetails} - renderRow={(p, rowNode) => { - // Every provider drills into its detail panel (parity with the - // per-provider dropdowns on main); Claude/Codex additionally get - // the account switcher + runtime toggle + Codex reset credits. - if (p.provider === 'claude') { - return ( - + - ) - } - if (p.provider === 'codex') { - return ( - - ) - } - return ( - + ))} + {collapseUsage ? ( + - - - ) : null} - {anyVisible && !isEmptyUsageState && ( - - - - - - {translate('auto.components.status.bar.StatusBar.c8857b40f7', 'Refresh usage data')} - - - )} -
- -
- -
- {!isPairedWebClientWindow() ? : null} - - - - - - {petEnabled ? : null} - {showResourceUsage ? ( - + ) : null} + + + + getUsageProviderAccountsSectionId(provider) !== null} + onManageAccounts={handleManageAccounts} + onUsageDetails={handleUsageDetails} + renderRow={(p, rowNode) => { + // Every provider drills into its detail panel (parity with the + // per-provider dropdowns on main); Claude/Codex additionally get + // the account switcher + runtime toggle + Codex reset credits. + if (p.provider === 'claude') { + return ( + + ) + } + if (p.provider === 'codex') { + return ( + + ) + } + return ( + + ) + }} + /> + + + ) : null} - {showPorts ? : null} - {showSsh ? : null} - - {showFloatingTerminalToggle && ( - + {anyVisible && !isEmptyUsageState && ( - {floatingTerminalActionLabel} ({floatingTerminalShortcut}) + {translate('auto.components.status.bar.StatusBar.c8857b40f7', 'Refresh usage data')} - - )} + )} +
+
+ + {/* Why: attention segments never clip; below the tightest density they scroll rather than vanish. */} +
+
+ {!isPairedWebClientWindow() ? ( + + ) : null} + + + + + + {petEnabled ? : null} + {showResourceUsage ? ( + + ) : null} + {showPorts ? ( + + ) : null} + {showSsh ? : null} + + {showFloatingTerminalToggle && ( + + + + + + + {floatingTerminalActionLabel} ({floatingTerminalShortcut}) + + + + )} +
diff --git a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx index a2dab03d9c0..c98f91c673e 100644 --- a/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx +++ b/src/renderer/src/components/status-bar/StatusBarVisibilityMenu.tsx @@ -133,6 +133,18 @@ export function StatusBarVisibilityMenu({ {translate('auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok Usage')} )} + {isStatusBarItemAvailable('zcode', detectedAgentIds) && ( + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('zcode') + }} + > + + {translate('auto.components.status.bar.StatusBar.zcodeUsageMenu', 'ZCode Usage')} + + )} { diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx index de4f5a0e06f..4f538ea852f 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -247,6 +247,35 @@ describe('UsageRow', () => { expect(markup).toContain('25%') expect(markup).toContain('60%') }) + + it('labels ZCode MCP separately from Coding Plan windows in both roster modes', () => { + const zcode: ProviderRateLimits = { + provider: 'zcode', + session: { usedPercent: 1, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 94, windowMinutes: 10_080, resetsAt: null, resetDescription: null }, + monthly: { usedPercent: 97, windowMinutes: 43_200, resetsAt: null, resetDescription: null }, + updatedAt: mocks.now, + status: 'ok', + error: null + } + const renderRow = (mode: 'verbose' | 'compact') => + renderToStaticMarkup( + + ) + + expect(renderRow('verbose')).toContain('data-usage-window="MCP"') + expect(renderRow('verbose')).toContain('MCP') + expect(renderRow('verbose')).not.toContain('30d') + expect(renderRow('compact')).toContain('MCP') + expect(renderRow('compact')).not.toContain('30d') + }) }) describe('UsageRosterPanel density picker', () => { diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx index feb066fadca..6163f1ba548 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -49,6 +49,9 @@ function shortLabel( if (section.window === p.fableWeekly) { return 'Fable' } + if (p.provider === 'zcode' && section.window === p.monthly) { + return section.label + } return useRemainingDuration ? formatRateLimitWindowChipLabel(section.window) : formatWindowLabel(section.window.windowMinutes) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts index cd98315df50..4c7ada35a47 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts @@ -20,6 +20,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', null)).toBe(true) expect(isStatusBarItemAvailable('antigravity', null)).toBe(true) expect(isStatusBarItemAvailable('grok', null)).toBe(true) + expect(isStatusBarItemAvailable('zcode', null)).toBe(true) }) it('hides CLI items not detected on PATH', () => { @@ -28,6 +29,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('antigravity', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) + expect(isStatusBarItemAvailable('zcode', ['claude', 'kimi'])).toBe(false) }) it('shows CLI items detected on PATH', () => { @@ -36,5 +38,6 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) expect(isStatusBarItemAvailable('antigravity', ['antigravity'])).toBe(true) expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) + expect(isStatusBarItemAvailable('zcode', ['zcode'])).toBe(true) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index 145abef3a90..85f641b96d2 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -13,7 +13,8 @@ const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'gemini', 'kimi', 'antigravity', - 'grok' + 'grok', + 'zcode' ]) export function isStatusBarItemAvailable( diff --git a/src/renderer/src/components/status-bar/status-bar-container-observer.ts b/src/renderer/src/components/status-bar/status-bar-container-observer.ts deleted file mode 100644 index 627b982efcd..00000000000 --- a/src/renderer/src/components/status-bar/status-bar-container-observer.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function observeStatusBarContainer( - node: HTMLDivElement, - onWidthChange: (width: number) => void -): ResizeObserver { - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - onWidthChange(entry.contentRect.width) - } - }) - observer.observe(node) - return observer -} diff --git a/src/renderer/src/components/status-bar/status-bar-density.test.ts b/src/renderer/src/components/status-bar/status-bar-density.test.ts new file mode 100644 index 00000000000..2197e74fef9 --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-density.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { + STATUS_BAR_DENSITY_LEVELS, + pickStatusBarDensityLevel, + recordStatusBarDensityWidth +} from './status-bar-density' + +const TIGHTEST = STATUS_BAR_DENSITY_LEVELS.length - 1 + +describe('pickStatusBarDensityLevel', () => { + it('keeps the roomiest level that fits', () => { + expect(pickStatusBarDensityLevel([700, 600, 500, 400, 300], 800)).toBe(0) + expect(pickStatusBarDensityLevel([700, 600, 500, 400, 300], 550)).toBe(2) + }) + + it('probes an unmeasured level before settling on a tighter one', () => { + expect(pickStatusBarDensityLevel([700, undefined, 500], 650)).toBe(1) + expect(pickStatusBarDensityLevel([], 650)).toBe(0) + }) + + it('falls back to the tightest level when nothing fits', () => { + expect(pickStatusBarDensityLevel([700, 600, 500, 400, 300], 200)).toBe(TIGHTEST) + }) + + it('tolerates sub-pixel rounding at the boundary', () => { + expect(pickStatusBarDensityLevel([600.6], 600)).toBe(0) + }) +}) + +describe('recordStatusBarDensityWidth', () => { + it('fills in a level without disturbing the others', () => { + expect(recordStatusBarDensityWidth([700, undefined, 500], 1, 600)).toEqual([700, 600, 500]) + }) + + it('forgets other levels when a level re-measures differently', () => { + // A segment appeared or vanished, so the other levels' widths no longer describe the bar. + expect(recordStatusBarDensityWidth([700, 600, 500], 1, 640)).toEqual([undefined, 640]) + }) + + it('keeps other levels on a sub-pixel re-measure', () => { + expect(recordStatusBarDensityWidth([700, 600, 500], 1, 600.5)).toEqual([700, 600.5, 500]) + }) +}) diff --git a/src/renderer/src/components/status-bar/status-bar-density.ts b/src/renderer/src/components/status-bar/status-bar-density.ts new file mode 100644 index 00000000000..078a4cf44df --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-density.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { MutableRefObject } from 'react' +import { measureUsageRow, pickCollapsedUsageChips } from './status-bar-usage-collapse' + +export type StatusBarDensity = { + /** Drop usage mini bars and secondary segment labels. */ + compact: boolean + /** One percentage per provider, whatever the chosen usage mode. */ + usageTightestOnly: boolean + /** Right-side segments show icon + count; tooltips carry the label. */ + segmentsIconOnly: boolean + /** Calm providers give way to a "+N" chip when even one percentage each doesn't fit. */ + collapseUsage: boolean +} + +// Why: ordered roomiest → tightest so each step gives up the least useful detail first; +// usage verbosity goes before right-side labels because the Usage popover holds it all. +export const STATUS_BAR_DENSITY_LEVELS: readonly StatusBarDensity[] = [ + { compact: false, usageTightestOnly: false, segmentsIconOnly: false, collapseUsage: false }, + { compact: true, usageTightestOnly: false, segmentsIconOnly: false, collapseUsage: false }, + { compact: true, usageTightestOnly: true, segmentsIconOnly: false, collapseUsage: false }, + { compact: true, usageTightestOnly: true, segmentsIconOnly: true, collapseUsage: true } +] +const NO_COLLAPSED_USAGE: readonly string[] = [] + +const WIDTH_TOLERANCE_PX = 1 + +/** Roomiest level known to fit; an unmeasured level is returned so it gets probed. */ +export function pickStatusBarDensityLevel( + requiredWidths: readonly (number | undefined)[], + availableWidth: number +): number { + const tightest = STATUS_BAR_DENSITY_LEVELS.length - 1 + for (let level = 0; level < tightest; level++) { + const required = requiredWidths[level] + if (required === undefined || required <= availableWidth + WIDTH_TOLERANCE_PX) { + return level + } + } + return tightest +} + +/** A level measuring differently than before means the content changed, so every other level's width is stale. */ +export function recordStatusBarDensityWidth( + requiredWidths: readonly (number | undefined)[], + level: number, + required: number +): (number | undefined)[] { + const previous = requiredWidths[level] + const next = + previous !== undefined && Math.abs(previous - required) > WIDTH_TOLERANCE_PX + ? [] + : [...requiredWidths] + next[level] = required + return next +} + +type ElementRef = MutableRefObject + +function measureWidth(ref: ElementRef): number { + return ref.current?.getBoundingClientRect().width ?? 0 +} + +/** + * Picks the roomiest density at which the usage cluster and the right-side segments fit + * on one line. Widths are measured from the rendered content, so a transient segment + * (update ready, chats to resume) condenses the bar only while it is present. At the + * tightest level it also names the usage chips that give way to a "+N" chip. + */ +export function useStatusBarDensity(): { + density: StatusBarDensity + overflowing: boolean + collapsedUsageProviders: readonly string[] + barRef: (node: HTMLElement | null) => void + usageRef: (node: HTMLElement | null) => void + segmentsRef: (node: HTMLElement | null) => void +} { + const [level, setLevel] = useState(0) + const [overflowing, setOverflowing] = useState(false) + const [collapsedUsageProviders, setCollapsedUsageProviders] = useState(NO_COLLAPSED_USAGE) + const committedLevelRef = useRef(0) + const overflowingRef = useRef(false) + const collapsedUsageRef = useRef(NO_COLLAPSED_USAGE) + const requiredWidthsRef = useRef<(number | undefined)[]>([]) + const barElementRef = useRef(null) + const usageElementRef = useRef(null) + const segmentsElementRef = useRef(null) + const observerRef = useRef(null) + + const evaluate = useCallback((): void => { + const bar = barElementRef.current + if (!bar) { + return + } + const style = getComputedStyle(bar) + const available = + bar.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight) + const usage = measureUsageRow(usageElementRef.current) + const fixedWidth = (Number.parseFloat(style.columnGap) || 0) + measureWidth(segmentsElementRef) + const required = usage.naturalWidth + fixedWidth + requiredWidthsRef.current = recordStatusBarDensityWidth( + requiredWidthsRef.current, + committedLevelRef.current, + required + ) + const nextLevel = pickStatusBarDensityLevel(requiredWidthsRef.current, available) + if (nextLevel !== committedLevelRef.current) { + setLevel(nextLevel) + } + const collapsible = + nextLevel === committedLevelRef.current && STATUS_BAR_DENSITY_LEVELS[nextLevel].collapseUsage + const nextCollapsed = collapsible + ? pickCollapsedUsageChips( + usage.chips, + required - available - WIDTH_TOLERANCE_PX, + usage.moreChipWidth, + usage.chipGap + ) + : NO_COLLAPSED_USAGE + if (nextCollapsed.join() !== collapsedUsageRef.current.join()) { + collapsedUsageRef.current = nextCollapsed + setCollapsedUsageProviders(nextCollapsed) + } + const nextOverflowing = usage.renderedWidth + fixedWidth > available + WIDTH_TOLERANCE_PX + if (nextOverflowing !== overflowingRef.current) { + overflowingRef.current = nextOverflowing + setOverflowing(nextOverflowing) + } + }, []) + + // Why: runs before paint, so probing a roomier level that turns out not to fit never flashes. + useLayoutEffect(() => { + committedLevelRef.current = level + evaluate() + }) + + useEffect(() => () => observerRef.current?.disconnect(), []) + + const refs = useMemo(() => { + const bind = + (target: ElementRef) => + (node: HTMLElement | null): void => { + observerRef.current ??= new ResizeObserver(() => evaluate()) + if (target.current) { + observerRef.current.unobserve(target.current) + } + target.current = node + if (node) { + observerRef.current.observe(node) + } + } + return { + barRef: bind(barElementRef), + usageRef: bind(usageElementRef), + segmentsRef: bind(segmentsElementRef) + } + }, [evaluate]) + + return { + density: STATUS_BAR_DENSITY_LEVELS[level], + overflowing, + collapsedUsageProviders, + ...refs + } +} diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index b144ddbdeae..987bb91c040 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -459,7 +459,8 @@ describe('isUsageEmptyState', () => { antigravity: provider('unavailable', { provider: 'antigravity' }), minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }), - cursor: provider('unavailable', { provider: 'cursor' }) + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) }, usageSettings() ) @@ -478,7 +479,8 @@ describe('isUsageEmptyState', () => { antigravity: provider('unavailable', { provider: 'antigravity' }), minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }), - cursor: provider('unavailable', { provider: 'cursor' }) + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) }, usageSettings({ codexManagedAccounts: [ @@ -501,22 +503,21 @@ describe('isUsageEmptyState', () => { }) it('shows the setup CTA for a loaded profile with no configured usage provider', () => { - expect( - isUsageEmptyState( - { - claude: provider('unavailable', { provider: 'claude' }), - codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), - opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }), - antigravity: null, - minimax: provider('unavailable', { provider: 'minimax' }), - grok: provider('unavailable', { provider: 'grok' }), - cursor: provider('unavailable', { provider: 'cursor' }) - }, - usageSettings() - ) - ).toBe(true) + const settledProviders = { + claude: provider('unavailable', { provider: 'claude' }), + codex: provider('unavailable', { provider: 'codex' }), + gemini: provider('unavailable'), + opencodeGo: provider('unavailable', { provider: 'opencode-go' }), + kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: null, + minimax: provider('unavailable', { provider: 'minimax' }), + grok: provider('unavailable', { provider: 'grok' }), + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) + } + expect(isUsageEmptyState(settledProviders, usageSettings())).toBe(true) + expect(isUsageEmptyState({ ...settledProviders, zcode: undefined }, usageSettings())).toBe(true) + expect(isUsageEmptyState({ ...settledProviders, zcode: null }, usageSettings())).toBe(false) }) it('does not show the setup CTA while checked Antigravity usage is awaiting a snapshot', () => { @@ -531,7 +532,8 @@ describe('isUsageEmptyState', () => { antigravity: null, grok: provider('unavailable', { provider: 'grok' }), minimax: provider('unavailable', { provider: 'minimax' }), - cursor: provider('unavailable', { provider: 'cursor' }) + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) }, usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) ) @@ -552,7 +554,8 @@ describe('isUsageEmptyState', () => { antigravity: null, grok: provider('unavailable', { provider: 'grok' }), minimax: provider('unavailable', { provider: 'minimax' }), - cursor: provider('unavailable', { provider: 'cursor' }) + cursor: provider('unavailable', { provider: 'cursor' }), + zcode: provider('unavailable', { provider: 'zcode' }) }, usageSettings({ antigravityUsageConfigured: true }) ) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 086540781d7..24392aa48ab 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -34,6 +34,7 @@ type UsageProviderSnapshots = { minimax: ProviderRateLimits | null | undefined grok: ProviderRateLimits | null | undefined cursor: ProviderRateLimits | null | undefined + zcode?: ProviderRateLimits | null } type UsageProviderId = ProviderRateLimits['provider'] @@ -181,7 +182,8 @@ export function isUsageEmptyState( antigravitySnapshotPending || isProviderSnapshotPending(providers.minimax) || isProviderSnapshotPending(providers.grok) || - isProviderSnapshotPending(providers.cursor) + isProviderSnapshotPending(providers.cursor) || + (providers.zcode !== undefined && isProviderSnapshotPending(providers.zcode)) ) { return false } @@ -195,6 +197,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.antigravity) && !isProviderConfigured(providers.minimax) && !isProviderConfigured(providers.grok) && - !isProviderConfigured(providers.cursor) + !isProviderConfigured(providers.cursor) && + !isProviderConfigured(providers.zcode) ) } diff --git a/src/renderer/src/components/status-bar/status-bar-usage-collapse.test.ts b/src/renderer/src/components/status-bar/status-bar-usage-collapse.test.ts new file mode 100644 index 00000000000..9abddbb902e --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-usage-collapse.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { pickCollapsedUsageChips } from './status-bar-usage-collapse' + +const GAP = 12 +const MORE = 20 +// Roster order, as the status bar renders it. +const chips = [ + { provider: 'claude', width: 60, urgent: false }, + { provider: 'codex', width: 60, urgent: false }, + { provider: 'gemini', width: 60, urgent: false }, + { provider: 'grok', width: 60, urgent: false }, + { provider: 'cursor', width: 60, urgent: true } +] + +describe('pickCollapsedUsageChips', () => { + it('collapses nothing when the row fits', () => { + expect(pickCollapsedUsageChips(chips, 0, MORE, GAP)).toEqual([]) + expect(pickCollapsedUsageChips(chips, -40, MORE, GAP)).toEqual([]) + }) + + it('drops calm agents from the end of the roster and keeps the urgent one', () => { + // 10px over: one chip frees 72px, which also pays for the "+N" chip (20 + 12). + expect(pickCollapsedUsageChips(chips, 10, MORE, GAP)).toEqual(['grok']) + expect(pickCollapsedUsageChips(chips, 60, MORE, GAP)).toEqual(['grok', 'gemini']) + }) + + it('reserves room for the "+N" chip it introduces', () => { + // 41px over + 32px for "+N" = 73px, one more than a single chip frees. + expect(pickCollapsedUsageChips(chips, 41, MORE, GAP)).toEqual(['grok', 'gemini']) + }) + + it('drops urgent agents only after every calm one', () => { + expect(pickCollapsedUsageChips(chips, 300, MORE, GAP)).toEqual([ + 'grok', + 'gemini', + 'codex', + 'claude', + 'cursor' + ]) + }) + + it('drops urgent agents from the end of the roster too', () => { + const allUrgent = chips.map((chip) => ({ ...chip, urgent: true })) + expect(pickCollapsedUsageChips(allUrgent, 10, MORE, GAP)).toEqual(['cursor']) + }) +}) diff --git a/src/renderer/src/components/status-bar/status-bar-usage-collapse.ts b/src/renderer/src/components/status-bar/status-bar-usage-collapse.ts new file mode 100644 index 00000000000..581b1b3643d --- /dev/null +++ b/src/renderer/src/components/status-bar/status-bar-usage-collapse.ts @@ -0,0 +1,77 @@ +export type UsageChipMeasure = { + provider: string + width: number + urgent: boolean + collapsed: boolean +} + +export type UsageRowMeasure = { + /** Width of the usage row as if every chip were shown, so collapsing never reads as a content change. */ + naturalWidth: number + /** Width the row actually occupies right now. */ + renderedWidth: number + chips: UsageChipMeasure[] + moreChipWidth: number + chipGap: number +} + +/** + * Chooses which usage chips give way so the row sheds `overflowPx`. Calm agents go first, + * from the end of the roster; urgent ones only when calm ones can't free enough room. + */ +export function pickCollapsedUsageChips( + chips: readonly Pick[], + overflowPx: number, + moreChipWidth: number, + chipGap: number +): string[] { + if (overflowPx <= 0) { + return [] + } + // The first collapsed chip brings the "+N" chip (and its gap) into the row. + const needed = overflowPx + moreChipWidth + chipGap + const dropOrder = [ + ...chips.filter((chip) => !chip.urgent).toReversed(), + ...chips.filter((chip) => chip.urgent).toReversed() + ] + const collapsed: string[] = [] + let freed = 0 + for (const chip of dropOrder) { + if (freed >= needed) { + break + } + collapsed.push(chip.provider) + freed += chip.width + chipGap + } + return collapsed +} + +/** Reads the `data-usage-*` markers the status bar renders on its usage chips and "+N" chip. */ +export function measureUsageRow(usage: HTMLElement | null): UsageRowMeasure { + if (!usage) { + return { naturalWidth: 0, renderedWidth: 0, chips: [], moreChipWidth: 0, chipGap: 0 } + } + const renderedWidth = usage.getBoundingClientRect().width + const chipElements = [...usage.querySelectorAll('[data-usage-chip]')] + const chipRow = chipElements[0]?.parentElement + const chipGap = chipRow ? Number.parseFloat(getComputedStyle(chipRow).columnGap) || 0 : 0 + const chips = chipElements.map((element) => ({ + provider: element.dataset.usageChip ?? '', + width: element.getBoundingClientRect().width, + urgent: element.dataset.usageUrgent === 'true', + collapsed: element.dataset.usageCollapsed === 'true' + })) + const more = usage.querySelector('[data-usage-more]') + const moreChipWidth = more?.getBoundingClientRect().width ?? 0 + const moreInRow = more !== null && more.dataset.usageCollapsed !== 'true' + const collapsedWidth = chips + .filter((chip) => chip.collapsed) + .reduce((sum, chip) => sum + chip.width + chipGap, 0) + return { + naturalWidth: renderedWidth + collapsedWidth - (moreInRow ? moreChipWidth + chipGap : 0), + renderedWidth, + chips, + moreChipWidth, + chipGap + } +} diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index fd7561e250c..f08f1e4a1eb 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -54,7 +54,8 @@ const PROVIDER_IDS: ProviderRateLimits['provider'][] = [ 'opencode-go', 'kimi', 'minimax', - 'grok' + 'grok', + 'zcode' ] afterEach(() => { @@ -294,6 +295,18 @@ describe('provider usage error copy', () => { }) describe('getWindowSections', () => { + it('keeps ZCode Coding Plan windows separate from MCP quota', () => { + const session = { usedPercent: 25, windowMinutes: 300, resetsAt: null, resetDescription: null } + const weekly = { usedPercent: 40, windowMinutes: 10080, resetsAt: null, resetDescription: null } + const mcp = { usedPercent: 10, windowMinutes: 43200, resetsAt: null, resetDescription: null } + const sections = getWindowSections( + provider({ provider: 'zcode', session, weekly, monthly: mcp }) + ) + + expect(sections.map((section) => section.label)).toEqual(['Session', 'Weekly', 'MCP']) + expect(sections[2].window).toBe(mcp) + }) + it('returns buckets as sections when present', () => { const p: ProviderRateLimits = { provider: 'gemini', diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index be15ae18c02..735452c9e26 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -97,6 +97,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'grok') { return } + if (provider === 'zcode') { + return + } if (provider === 'cursor') { return } @@ -186,7 +189,10 @@ export function getWindowSections( } if (p.monthly !== undefined && p.monthly !== null) { sections.push({ - label: translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'), + label: + p.provider === 'zcode' + ? translate('auto.components.status.bar.tooltip.zcode.mcp', 'MCP') + : translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'), window: p.monthly }) } @@ -204,11 +210,14 @@ export function getWindowSections( // Why: urgency color tracks % used even when fill represents % remaining; // low usage stays neutral so persistent chrome stays quiet. +export const USAGE_WARNING_PERCENT = 60 +export const USAGE_URGENT_PERCENT = 80 + export function barColor(usedPct: number): string { - if (usedPct < 60) { + if (usedPct < USAGE_WARNING_PERCENT) { return 'bg-muted-foreground/40' } - if (usedPct < 80) { + if (usedPct < USAGE_URGENT_PERCENT) { return 'bg-yellow-500' } return 'bg-red-500' diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index a0a6cabf1a9..95942ddb5b7 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -26,6 +26,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'grok') { return 'Grok' } + if (provider === 'zcode') { + return 'ZCode' + } if (provider === 'cursor') { return 'Cursor' } diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index 39380e13595..b531bffd161 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -21,7 +21,9 @@ export function getUsageProviderAccountsSectionId( case 'cursor': return 'accounts-cursor' case 'kimi': + case 'zcode': // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + // ZCode likewise owns its Coding Plan credential in ~/.zcode/cli/config.json. return null } } diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index b7f57c7f1e7..8ec0bcfce43 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -9,7 +9,7 @@ import { isStatusBarItemAvailable } from './status-bar-agent-gating' import { getVisibleUsageProvider, isUsageEmptyState } from './status-bar-provider-visibility' import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' import { CLOSE_ALL_CONTEXT_MENUS_EVENT, useStatusBarMenuFocusHandoff } from './ProviderDetailsMenu' -import { observeStatusBarContainer } from './status-bar-container-observer' +import { useStatusBarDensity } from './status-bar-density' export function useStatusBarController(floatingTerminalOpen: boolean) { const floatingTerminalShortcut = useShortcutLabel('floatingTerminal.toggle') @@ -40,14 +40,18 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) const toggleStatusBarItem = useAppStore((s) => s.toggleStatusBarItem) const usageEmptyStateDismissed = useAppStore((s) => s.usageEmptyStateDismissed) - const containerRef = useRef(null) const mountedRef = useRef(true) const [isRefreshing, setIsRefreshing] = useState(false) const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) - - const [containerWidth, setContainerWidth] = useState(900) - const resizeObserverRef = useRef(null) + const { + density: { compact, usageTightestOnly, segmentsIconOnly, collapseUsage }, + overflowing, + collapsedUsageProviders, + barRef, + usageRef, + segmentsRef + } = useStatusBarDensity() useEffect(() => { mountedRef.current = true @@ -67,18 +71,6 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { void ensureDetectedAgents() }, [ensureDetectedAgents]) - const containerRefCallback = useCallback((node: HTMLDivElement | null) => { - if (resizeObserverRef.current) { - resizeObserverRef.current.disconnect() - resizeObserverRef.current = null - } - if (node) { - containerRef.current = node - resizeObserverRef.current = observeStatusBarContainer(node, setContainerWidth) - setContainerWidth(node.getBoundingClientRect().width) - } - }, []) - const refreshDetectedAgents = useAppStore((s) => s.refreshDetectedAgents) const handleRefresh = useCallback(async () => { if (isRefreshing) { @@ -99,7 +91,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { return null } - const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, zcode } = + rateLimits // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. @@ -125,6 +118,7 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings) const visibleCursor = getVisibleUsageProvider('cursor', cursor, usageSettings) + const visibleZcode = getVisibleUsageProvider('zcode', zcode, usageSettings) const showClaude = visibleClaude !== null && statusBarItems.includes('claude') && @@ -154,6 +148,10 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { // Why: a Cursor session can come from the IDE alone, so PATH detection of // cursor-agent would hide a real meter from IDE-only users. const showCursor = visibleCursor !== null && statusBarItems.includes('cursor') + const showZcode = + visibleZcode !== null && + statusBarItems.includes('zcode') && + isStatusBarItemAvailable('zcode', detectedAgentIds) // Why: OpenCode Go is web/cookie-auth, not a CLI on PATH, so detection-gating doesn't apply. const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go') @@ -172,11 +170,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showAntigravity || showMiniMax || showGrok || - showCursor + showCursor || + showZcode const anyVisible = hasVisibleUsageMeters || showResourceUsage // Why: include Settings so durable managed accounts count — a configured user isn't shown the empty state while snapshots hydrate. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor }, + { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, cursor, zcode }, usageSettings ) // Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later. @@ -190,10 +189,9 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { antigravity?.status === 'fetching' || minimax?.status === 'fetching' || grok?.status === 'fetching' || - cursor?.status === 'fetching' + cursor?.status === 'fetching' || + zcode?.status === 'fetching' - const compact = containerWidth < 900 - const iconOnly = containerWidth < 500 const floatingTerminalActionLabel = floatingTerminalOpen ? 'Minimize Floating Workspace' : 'Show Floating Workspace' @@ -210,7 +208,8 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { showKimi ? visibleKimi : null, showMiniMax ? visibleMiniMax : null, showGrok ? visibleGrok : null, - showCursor ? visibleCursor : null + showCursor ? visibleCursor : null, + showZcode ? visibleZcode : null ].filter((p): p is ProviderRateLimits => p !== null) const handleManageAccounts = (): void => { @@ -243,8 +242,10 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { return { anyFetching, anyVisible, + barRef, + collapseUsage, + collapsedUsageProviders, compact, - containerRefCallback, detectedAgentIds, floatingTerminalActionLabel, floatingTerminalShortcut, @@ -254,14 +255,16 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { handleUsageDetails, handleUsageMenuOpenChange, hasVisibleUsageMeters, - iconOnly, isEmptyUsageState, isRefreshing, menuOpen, menuPoint, + overflowing, petEnabled, recordFeatureInteraction, rosterProviders, + segmentsIconOnly, + segmentsRef, setMenuOpen, setMenuPoint, setStatusBarUsageMode, @@ -276,7 +279,9 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { toggleStatusBarItem, usageMenuFocusHandoff, usageMenuOpen, - usagePercentageDisplay + usagePercentageDisplay, + usageRef, + usageTightestOnly } } diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index bd25abcce74..16f87ae24a9 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -78,6 +78,7 @@ function renderMenu(overrides: Record = {}): string { onEqualizePaneSizes: vi.fn(), onClosePane: vi.fn(), onClearScreen: vi.fn(), + onResetTerminal: vi.fn(), canContinueAgentSessionInNewSession: false, onContinueAgentSessionInNewSession: vi.fn(), onForkAgentSession: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 5236d61d1a6..e09969753e9 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -12,6 +12,7 @@ import { PanelsTopLeft, PanelRightClose, Pencil, + RotateCcw, SquareTerminal, TextSelect, X @@ -53,6 +54,7 @@ type TerminalContextMenuProps = { onEqualizePaneSizes: () => void onClosePane: () => void onClearScreen: () => void + onResetTerminal: () => void canContinueAgentSessionInNewSession: boolean onContinueAgentSessionInNewSession: () => void onForkAgentSession: () => void @@ -144,6 +146,7 @@ function TerminalContextMenuItems({ onEqualizePaneSizes, onClosePane, onClearScreen, + onResetTerminal, canContinueAgentSessionInNewSession, onContinueAgentSessionInNewSession, onForkAgentSession, @@ -350,6 +353,13 @@ function TerminalContextMenuItems({ {translate('auto.components.terminal.pane.TerminalContextMenu.b4cdd9314e', 'Clear Screen')} + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.resetTerminal', + 'Reset Terminal' + )} + ) } diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index 31febbd6c21..18069a846b5 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -361,6 +361,19 @@ describe('TerminalErrorToast environment footer', () => { await waitFor(() => expect(environmentMocks.resolveFooter).not.toHaveBeenCalled()) }) + it('shows the issue request once for a host error that already asks for one', () => { + const view = render( + React.createElement(TerminalErrorToast, { + error: + 'Failed to spawn shell "/bin/zsh": boom (shell: /bin/zsh). If this persists, please file an issue.', + onDismiss: vi.fn() + }) + ) + + expect(view.container.textContent?.match(/If this persists/g)).toHaveLength(1) + expect(view.container.textContent).toContain('(shell: /bin/zsh).') + }) + it('renders owner-unverified as a warning without an issue link', () => { const onRetry = vi.fn().mockResolvedValue(true) const view = render( diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index 8b627a9651f..a31b0c37856 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -3,6 +3,10 @@ import { translate } from '@/i18n/i18n' import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info' import { Button } from '@/components/ui/button' import { hasClientEnvironmentFooter } from '../../../../shared/client-environment-info' +import { + localizeTerminalSpawnHints, + withoutTerminalSpawnIssueRequest +} from './terminal-spawn-error-display' const SSH_PREFIX = 'SSH connection is not active' // Produced by pty-connection.ts reportError() when a PTY reattach can't reach its SSH host. @@ -112,7 +116,7 @@ function humanizeUnreattachableSession(error: string): string { /** Swaps raw daemon-boundary codes for copy a user can act on. */ export function humanizeTerminalError(error: string): string { - let humanized = error + let humanized = localizeTerminalSpawnHints(error) if (humanized.includes(PANE_OWNER_UNVERIFIED_MARKER)) { const explanation = isPaneOwnerUnverifiedError(humanized) ? translate( @@ -178,7 +182,11 @@ export function TerminalErrorToast({ // Restart cannot recover a session after its owning daemon exits. const showIssueLink = !ssh && !paneOwnerUnverified && !showDaemonRestart && !isExplainedTerminalError(error) - const displayError = humanizeTerminalError(error) + const humanizedError = humanizeTerminalError(error) + // Why: the toast appends its own linked request, so the host's plain-text one would repeat it. + const displayError = showIssueLink + ? withoutTerminalSpawnIssueRequest(humanizedError) + : humanizedError const tint = paneOwnerUnverified ? null : ssh diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index 08613fb6c3a..0b5da641c95 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -242,6 +242,7 @@ export function TerminalPaneSurface({ onEqualizePaneSizes={contextMenu.onEqualizePaneSizes} onClosePane={contextMenu.onClosePane} onClearScreen={contextMenu.onClearScreen} + onResetTerminal={contextMenu.onResetTerminal} canContinueAgentSessionInNewSession={contextMenuCanContinueInNewSession} onContinueAgentSessionInNewSession={contextMenu.onContinueAgentSessionInNewSession} onForkAgentSession={() => void contextMenu.onForkAgentSession()} diff --git a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts index cba19b43c2f..c77339bfe2e 100644 --- a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts +++ b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts @@ -30,39 +30,52 @@ describe('deferred split pane handoff', () => { const cwdPromise = Promise.resolve('/source/cwd') const initial = beginDeferredSplitPaneHandoff(key, cwdPromise) - appendDeferredSplitPaneInput(initial, { data: 'typed', kind: 'ordinary' }) - appendDeferredSplitPaneInput(initial, { data: '\x1b[0n', kind: 'immediate' }) - appendDeferredSplitPaneInput(initial, { data: '\x03', kind: 'accepted' }) + appendDeferredSplitPaneInput(initial, { data: 'typed', kind: 'ordinary', inputKind: 'driving' }) + appendDeferredSplitPaneInput(initial, { + data: '\x1b[0n', + kind: 'immediate', + inputKind: 'query-reply' + }) + appendDeferredSplitPaneInput(initial, { data: '\x03', kind: 'accepted', inputKind: 'driving' }) const remounted = claimDeferredSplitPaneHandoff(key) expect(remounted?.cwdPromise).toBe(cwdPromise) expect(remounted?.preconnectInput).toEqual([ - { data: 'typed', kind: 'ordinary' }, - { data: '\x1b[0n', kind: 'immediate' }, - { data: '\x03', kind: 'accepted' } + { data: 'typed', kind: 'ordinary', inputKind: 'driving' }, + { data: '\x1b[0n', kind: 'immediate', inputKind: 'query-reply' }, + { data: '\x03', kind: 'accepted', inputKind: 'driving' } ]) }) it('keeps input across repeated remounts and fences stale owners', () => { const key = makePaneKey('tab-1', LEAF_1) const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(initial, { data: 'before-first-remount', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'before-first-remount', + kind: 'ordinary', + inputKind: 'driving' + }) const firstRemount = claimDeferredSplitPaneHandoff(key) expect(firstRemount).not.toBeNull() - appendDeferredSplitPaneInput(initial, { data: 'stale-input', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'stale-input', + kind: 'ordinary', + inputKind: 'driving' + }) clearDeferredSplitPaneHandoff(initial) clearDeferredSplitPaneHandoff(initial) appendDeferredSplitPaneInput(firstRemount!.handle, { data: 'before-second-remount', - kind: 'ordinary' + kind: 'ordinary', + inputKind: 'driving' }) const secondRemount = claimDeferredSplitPaneHandoff(key) expect(secondRemount?.preconnectInput).toEqual([ - { data: 'before-first-remount', kind: 'ordinary' }, - { data: 'before-second-remount', kind: 'ordinary' } + { data: 'before-first-remount', kind: 'ordinary', inputKind: 'driving' }, + { data: 'before-second-remount', kind: 'ordinary', inputKind: 'driving' } ]) clearDeferredSplitPaneHandoff(firstRemount!.handle) @@ -74,21 +87,33 @@ describe('deferred split pane handoff', () => { it('releases an unmounted owner without dropping its pending handoff', () => { const key = makePaneKey('tab-1', LEAF_1) const initial = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(initial, { data: 'before-unmount', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'before-unmount', + kind: 'ordinary', + inputKind: 'driving' + }) releaseDeferredSplitPaneHandoff(initial) - appendDeferredSplitPaneInput(initial, { data: 'late-stale', kind: 'ordinary' }) + appendDeferredSplitPaneInput(initial, { + data: 'late-stale', + kind: 'ordinary', + inputKind: 'driving' + }) clearDeferredSplitPaneHandoff(initial) expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([ - { data: 'before-unmount', kind: 'ordinary' } + { data: 'before-unmount', kind: 'ordinary', inputKind: 'driving' } ]) }) it('lets a late close discard a released handoff by its stable pane key', () => { const key = makePaneKey('tab-1', LEAF_1) const owner = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(owner, { data: 'must-not-replay', kind: 'ordinary' }) + appendDeferredSplitPaneInput(owner, { + data: 'must-not-replay', + kind: 'ordinary', + inputKind: 'driving' + }) // Whole-tab cleanup releases the mount-local handle before a stale close callback can run. releaseDeferredSplitPaneHandoff(owner) @@ -112,7 +137,11 @@ describe('deferred split pane handoff', () => { it('drops a stale record when an authoritative restored PTY wins the key', () => { const key = makePaneKey('tab-authoritative', LEAF_1) const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale')) - appendDeferredSplitPaneInput(stale, { data: 'must-not-replay', kind: 'ordinary' }) + appendDeferredSplitPaneInput(stale, { + data: 'must-not-replay', + kind: 'ordinary', + inputKind: 'driving' + }) discardDeferredSplitPaneHandoffForKey(key) @@ -123,26 +152,44 @@ describe('deferred split pane handoff', () => { it('replaces an older handoff for the same stable pane key', () => { const key = makePaneKey('tab-1', LEAF_1) const stale = beginDeferredSplitPaneHandoff(key, Promise.resolve('/stale')) - appendDeferredSplitPaneInput(stale, { data: 'stale', kind: 'ordinary' }) + appendDeferredSplitPaneInput(stale, { data: 'stale', kind: 'ordinary', inputKind: 'driving' }) const currentPromise = Promise.resolve('/current') const current = beginDeferredSplitPaneHandoff(key, currentPromise) - appendDeferredSplitPaneInput(stale, { data: 'late-stale', kind: 'ordinary' }) + appendDeferredSplitPaneInput(stale, { + data: 'late-stale', + kind: 'ordinary', + inputKind: 'driving' + }) clearDeferredSplitPaneHandoff(stale) - appendDeferredSplitPaneInput(current, { data: 'current', kind: 'ordinary' }) + appendDeferredSplitPaneInput(current, { + data: 'current', + kind: 'ordinary', + inputKind: 'driving' + }) const claimed = claimDeferredSplitPaneHandoff(key) expect(claimed?.cwdPromise).toBe(currentPromise) - expect(claimed?.preconnectInput).toEqual([{ data: 'current', kind: 'ordinary' }]) + expect(claimed?.preconnectInput).toEqual([ + { data: 'current', kind: 'ordinary', inputKind: 'driving' } + ]) }) it('retains input within the shared preconnect entry and code-unit caps', () => { const entryKey = makePaneKey('tab-entries', LEAF_1) const entryHandle = beginDeferredSplitPaneHandoff(entryKey, Promise.resolve('/entries')) for (let index = 0; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) { - appendDeferredSplitPaneInput(entryHandle, { data: '', kind: 'ordinary' }) + appendDeferredSplitPaneInput(entryHandle, { + data: '', + kind: 'ordinary', + inputKind: 'driving' + }) } - appendDeferredSplitPaneInput(entryHandle, { data: 'overflow', kind: 'ordinary' }) + appendDeferredSplitPaneInput(entryHandle, { + data: 'overflow', + kind: 'ordinary', + inputKind: 'driving' + }) expect(claimDeferredSplitPaneHandoff(entryKey)?.preconnectInput).toHaveLength( PTY_PRECONNECT_INPUT_MAX_ENTRIES ) @@ -154,11 +201,20 @@ describe('deferred split pane handoff', () => { ) appendDeferredSplitPaneInput(codeUnitHandle, { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), - kind: 'ordinary' + kind: 'ordinary', + inputKind: 'driving' + }) + appendDeferredSplitPaneInput(codeUnitHandle, { + data: 'overflow', + kind: 'ordinary', + inputKind: 'driving' }) - appendDeferredSplitPaneInput(codeUnitHandle, { data: 'overflow', kind: 'ordinary' }) expect(claimDeferredSplitPaneHandoff(codeUnitKey)?.preconnectInput).toEqual([ - { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' } + { + data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), + kind: 'ordinary', + inputKind: 'driving' + } ]) }) @@ -209,12 +265,12 @@ describe('deferred split pane handoff', () => { it('does not expose the registry input array by reference', () => { const key = makePaneKey('tab-1', LEAF_1) const handle = beginDeferredSplitPaneHandoff(key, Promise.resolve('/source/cwd')) - appendDeferredSplitPaneInput(handle, { data: 'kept', kind: 'ordinary' }) + appendDeferredSplitPaneInput(handle, { data: 'kept', kind: 'ordinary', inputKind: 'driving' }) const firstClaim = claimDeferredSplitPaneHandoff(key) firstClaim?.preconnectInput.splice(0) expect(claimDeferredSplitPaneHandoff(key)?.preconnectInput).toEqual([ - { data: 'kept', kind: 'ordinary' } + { data: 'kept', kind: 'ordinary', inputKind: 'driving' } ]) }) }) diff --git a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts index 6dfa1f30866..046b43daa9c 100644 --- a/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts +++ b/src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.ts @@ -135,7 +135,7 @@ export function appendDeferredSplitPaneInput( ) { return } - owned.record.preconnectInput.push({ data: input.data, kind: input.kind }) + owned.record.preconnectInput.push({ ...input }) owned.record.inputCodeUnits += input.data.length } diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 39a3ce0c5af..ea5a4dda3aa 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -1,5 +1,5 @@ import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' -import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { readIpcErrorDetail } from '@/lib/ipc-error' import { ensurePtyDispatcher } from './pty-dispatcher' import { clearConsumedPreHandlerPtyExit, @@ -181,10 +181,8 @@ function handleConnectError( context: IpcPtyConnectContext ): PtyConnectResult | undefined { const { connectionId } = context.transportOptions - const message = extractIpcErrorMessage( - error, - error instanceof Error ? error.message : String(error) - ) + const message = + readIpcErrorDetail(error) ?? (error instanceof Error ? error.message : String(error)) if (connectionId && options.sessionId && isSshSessionGoneError(message)) { return { id: options.sessionId, sessionExpired: true } } diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx index 7e6f9a02549..8a84eddbcef 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime-enter-keyup.test.tsx @@ -328,7 +328,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => { vi.runAllTimers() expect(harness.sendInput).toHaveBeenCalledTimes(1) - expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving') hook.unmount() harness.dispose() }) @@ -351,7 +351,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => { vi.runAllTimers() expect(harness.sendInput).toHaveBeenCalledTimes(1) - expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving') hook.unmount() harness.dispose() }) @@ -374,7 +374,7 @@ describe('Windows IME Enter-keyup press-time evidence', () => { expect(harness.setActivePane).toHaveBeenCalledWith(2, { focus: false }) expect(harness.sendInput).toHaveBeenCalledOnce() - expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r') + expect(harness.sendInput).toHaveBeenCalledWith('\x1b\r', 'driving') hook.unmount() harness.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index 8980c86ee8d..5835246ebaf 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -38,7 +38,6 @@ import { POST_REPLAY_MODE_RESET, replayPayloadEndsWithCursorHidden, RESET_GRAPHIC_RENDITION, - RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' import { @@ -462,7 +461,7 @@ describe('replayPayloadEndsWithCursorHidden', () => { describe('buildPostReplayLiveAgentReattachReset', () => { it('preserves an intentionally hidden cursor', () => { expect(buildPostReplayLiveAgentReattachReset('agent frame\x1b[?25l')).toBe( - `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` + RESET_TERMINAL_CURSOR_STYLE ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts index 4adda77cc20..f72596187fb 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts @@ -6,6 +6,7 @@ describe('pty buffer serializer registry', () => { beforeEach(() => { vi.resetModules() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only these pty members. ;(globalThis as { window: typeof window }).window = { ...originalWindow, api: { @@ -13,6 +14,7 @@ describe('pty buffer serializer registry', () => { pty: { ...originalWindow?.api?.pty, onClearBufferRequest: vi.fn(() => () => {}), + onResetInputModesRequest: vi.fn(() => () => {}), onSerializeBufferRequest: vi.fn(() => () => {}), sendSerializedBuffer: vi.fn() } @@ -120,4 +122,16 @@ describe('pty buffer serializer registry', () => { expect.objectContaining({ pendingEscapeTailAnsi: '\x1b[38;5;' }) ) }) + + it("routes a host's Reset Terminal request to the pane that owns the PTY", async () => { + const { registerPtySerializer } = await import('./pty-buffer-serializer') + const resetInputModes = vi.fn() + registerPtySerializer('pty-1', () => null, { resetInputModes }) + const [[onRequest]] = vi.mocked(window.api.pty.onResetInputModesRequest).mock.calls + + onRequest({ ptyId: 'pty-2' }) + expect(resetInputModes).not.toHaveBeenCalled() + onRequest({ ptyId: 'pty-1' }) + expect(resetInputModes).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts index 35d6a833050..ada99d6cbe8 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts @@ -34,6 +34,7 @@ export type SerializeFn = ( type SerializerEntry = { fn: SerializeFn clear?: () => void + resetInputModes?: () => void owner: symbol } @@ -50,10 +51,10 @@ let listenerAttached = false export function registerPtySerializer( ptyId: string, serialize: SerializeFn, - clear?: () => void + actions: Pick = {} ): () => void { const owner = Symbol(ptyId) - serializersByPtyId.set(ptyId, { fn: serialize, clear, owner }) + serializersByPtyId.set(ptyId, { fn: serialize, ...actions, owner }) ensureSerializerListener() return () => { const current = serializersByPtyId.get(ptyId) @@ -116,6 +117,11 @@ export function registerPtyTitleSource( } } +/** Grounds the pane's own records only; the host grounds its models on its own request. */ +export function resetPtyRendererInputModes(ptyId: string): void { + serializersByPtyId.get(ptyId)?.resetInputModes?.() +} + export function hasPtySerializer(ptyId: string): boolean { return serializersByPtyId.has(ptyId) } @@ -133,6 +139,8 @@ function ensureSerializerListener(): void { serializersByPtyId.get(request.ptyId)?.clear?.() }) + window.api.pty.onResetInputModesRequest((request) => resetPtyRendererInputModes(request.ptyId)) + window.api.pty.onSerializeBufferRequest((request) => { const entry = serializersByPtyId.get(request.ptyId) void Promise.resolve(entry?.fn(request.opts) ?? null) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts index 2a20be95575..088c2c54905 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-daemon-snapshot-replay.test.ts @@ -6,6 +6,7 @@ import { POST_REPLAY_REATTACH_RESET, RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' import { Terminal } from '@xterm/headless' import { flushAsyncTicks, createDeferred, writeHeadlessTerminal } from './pty-connection-test-async' import { createRect } from './pty-connection-test-dom' @@ -192,11 +193,11 @@ describe('connectPanePty', () => { expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, + replayEpilogue(POST_REPLAY_REATTACH_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_MODE_RESET, + replayEpilogue(POST_REPLAY_MODE_RESET, 0), expect.any(Function) ) @@ -252,7 +253,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_DEAD_TUI_RESET, + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), expect.any(Function) ) }) @@ -461,7 +462,7 @@ describe('connectPanePty', () => { expect(writes.join('')).toContain('RESTORE-LIVE-STATE') expect(writes.join('')).not.toContain('ALT-FRAME-BODY') expect(writes).toContain(`${RESET_GRAPHIC_RENDITION}PREFIX-SCROLLBACKRESTORE-LIVE-STATE`) - expect(writes).toContain(POST_REPLAY_MODE_RESET) + expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) }) it('resizes the pane to the snapshot grid before replaying daemon snapshot bytes (bug #7279)', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts index 743f06f6f42..f2dfe0bd637 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-write-path.test.ts @@ -240,7 +240,7 @@ describe('connectPanePty', () => { const forward = deferPtyInput.mock.calls[0]?.[2] as (data: string) => void forward('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('forwards terminal input directly when the host supplies no deferPtyInput', async () => { @@ -253,7 +253,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() sendTerminalInputThroughPane(pane, 'a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps large ANSI redraws after captured shortcut input on the immediate path', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts index 17511577376..408ce503b7e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts @@ -177,28 +177,6 @@ describe('connectPanePty', () => { ) }) - // Why: a late exit from a replaced PTY skips onExit's kitty reset, so a fresh spawn must reset the reused per-pane tracker itself or restart-in-place leaks old kitty flags. - it('resets a stale kitty keyboard mirror when spawning a fresh PTY', async () => { - const { connectPanePty } = await import('./pty-connection') - const { TerminalKittyKeyboardModeTracker } = - await import('../../../../shared/terminal-kitty-keyboard-mode-tracker') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - const staleTracker = new TerminalKittyKeyboardModeTracker() - staleTracker.scan('\x1b[>1u') - expect(staleTracker.flags).toBe(1) - // Why: a unique tab id keeps this pane's key clear of other tests' pendingSpawnByPaneKey entries so the connect deterministically fresh-spawns. - const deps = createDeps({ - tabId: 'tab-kitty-fresh-spawn', - paneKittyKeyboardModesRef: { current: new Map([[91, staleTracker]]) } - }) - - connectPanePty(createPane(91) as never, createManager(91) as never, deps as never) - await flushAsyncTicks() - - expect(staleTracker.flags).toBe(0) - }) - // Why: deleting a worktree kills its PTYs for the filesystem teardown; the // renderer must not race a doomed respawn into a directory main is deleting // (main fences it with TerminalRemovalInProgressError and the pane is about to @@ -490,7 +468,7 @@ describe('connectPanePty', () => { // Released (via the guard's fallback or parse completion): input flows again. deps.replayingPanesRef.current.delete(pane.id) sendTerminalInputThroughPane(pane, 'echo hi\r') - expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r') + expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r', 'query-reply') }) it('preserves classified user input during replay while suppressing synthetic replies', async () => { @@ -533,7 +511,7 @@ describe('connectPanePty', () => { for (const forward of deferred.splice(0)) { forward() } - expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r') + expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') // A wheel over a replayed alt-screen frame becomes cursor keys; the fresh shell must not recall history from them. pane.terminal.buffer.active.type = 'alternate' @@ -544,7 +522,7 @@ describe('connectPanePty', () => { for (const forward of deferred.splice(0)) { forward() } - expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r') + expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') // The same bytes on the normal buffer can only be a keyboard arrow, which survives replay. pane.terminal.buffer.active.type = 'normal' @@ -556,7 +534,7 @@ describe('connectPanePty', () => { forward() } expect(transport.sendInput).toHaveBeenCalledTimes(2) - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B') + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B', 'driving') // Once the guard releases, the same mouse report is ordinary input again. deps.replayingPanesRef.current.delete(pane.id) @@ -567,7 +545,7 @@ describe('connectPanePty', () => { for (const forward of deferred.splice(0)) { forward() } - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M') + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M', 'driving') }) it('settles a queued startup only after the pane binds its spawned PTY', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts index fd279a38f9d..7855e756226 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-wake.test.ts @@ -738,7 +738,7 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[O') expect(mockStoreState.recordTerminalInput).not.toHaveBeenCalled() // The reply still reaches the shell; only the activity recording is gated. - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O', 'query-reply') // Real user input fires the core signal and records activity. for (const listener of userInputListeners) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts index fe18699c20d..e9443188b95 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts @@ -6,6 +6,7 @@ import { POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_REATTACH_RESET } from '../../../../shared/terminal-mode-reset-profiles' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' import { makePaneKey } from '../../../../shared/stable-pane-id' import { flushAsyncTicks } from './pty-connection-test-async' import { @@ -520,15 +521,15 @@ describe('connectPanePty', () => { // A live agent owns ?1004h (focus reporting); the plain reset's ?1004l would silence focus events until restart, since agents only enable it at startup. expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + replayEpilogue(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_LIVE_SNAPSHOT_RESET, + replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_DEAD_TUI_RESET, + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), expect.any(Function) ) } finally { @@ -583,11 +584,11 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_DEAD_TUI_RESET, + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + replayEpilogue(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET), expect.any(Function) ) } finally { @@ -634,15 +635,15 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_LIVE_SNAPSHOT_RESET, + replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, + replayEpilogue(POST_REPLAY_REATTACH_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_DEAD_TUI_RESET, + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), expect.any(Function) ) } finally { @@ -689,11 +690,11 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_LIVE_SNAPSHOT_RESET, + replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, + replayEpilogue(POST_REPLAY_REATTACH_RESET), expect.any(Function) ) } finally { @@ -739,11 +740,11 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_DEAD_TUI_RESET, + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_LIVE_SNAPSHOT_RESET, + replayEpilogue(POST_REPLAY_LIVE_SNAPSHOT_RESET), expect.any(Function) ) expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts index e315563f0c6..ef49288f3eb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-bell-arbitration.test.ts @@ -528,7 +528,7 @@ describe('connectPanePty', () => { 'terminal-bell' ) expect(deps.clearTerminalPaneUnread).not.toHaveBeenCalled() - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) // Why: xterm auto-replies during replay must not count as user interaction, or a BELed pane would self-dismiss unseen. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts index e61460ee490..2845be5a64d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hook-completion-side-effects.test.ts @@ -1,9 +1,6 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - RESET_KITTY_KEYBOARD_PROTOCOL, - RESET_TERMINAL_CURSOR_STYLE -} from '../../../../shared/terminal-mode-reset-profiles' +import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' import { makePaneKey } from '../../../../shared/stable-pane-id' import { flushAsyncTicks } from './pty-connection-test-async' import { AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS } from './pty-connection-test-constants' @@ -456,15 +453,15 @@ describe('connectPanePty', () => { expect(deps.dispatchNotification).toHaveBeenCalledWith(expectedNotification) } expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + RESET_TERMINAL_CURSOR_STYLE, expect.any(Function) ) transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[I') sendTerminalInputThroughPane(pane, '\x7f') expect(transport.sendInput).toHaveBeenCalledTimes(2) - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[I') - expect(transport.sendInput).toHaveBeenLastCalledWith('\x7f') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[I', 'query-reply') + expect(transport.sendInput).toHaveBeenLastCalledWith('\x7f', 'query-reply') } finally { restoreUserAgent() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts index 4b1f376b773..7e9034c1d61 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts @@ -364,7 +364,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(500) await flushAsyncTicks() - expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(transport.sendInput).toHaveBeenCalledWith('\x03', 'query-reply') expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() expect(deps.updateTabTitle).not.toHaveBeenCalled() @@ -478,7 +478,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(500) await flushAsyncTicks() - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u') + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u', 'query-reply') expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ paneKey, baselineUpdatedAt: 1_000, @@ -652,7 +652,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(500) await flushAsyncTicks() - expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(transport.sendInput).toHaveBeenCalledWith('\x03', 'query-reply') expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts new file mode 100644 index 00000000000..9faaaf10eb3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-kitty-keyboard-restore.test.ts @@ -0,0 +1,233 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + POST_REPLAY_REATTACH_RESET, + PROCESS_BOUNDARY_GROUND +} from '../../../../shared/terminal-mode-reset-profiles' +import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { + info: toastInfo + } +})) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + +// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) +} + +// Why: xterm and the pane mirror must end every restore on the same kitty flags. +describe('connectPanePty kitty keyboard restore', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + async function reattachWithSnapshotFlags(kittyKeyboard: boolean) { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'live app frame', + snapshotKittyKeyboardFlags: 31, + snapshotSeq: 7 + } + : null + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.options.vtExtensions.kittyKeyboard = kittyKeyboard + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pane-connection mocks cover the members connectPanePty reads. + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + return { pane, mirror: deps.paneKittyKeyboardModesRef.current.get(pane.id) } + } + + it("restores a live app's host-proven flags into xterm and the mirror on reattach", async () => { + const { pane, mirror } = await reattachWithSnapshotFlags(true) + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${POST_REPLAY_REATTACH_RESET}\x1b[<99u\x1b[=31u`, + expect.any(Function) + ) + expect(mirror?.flags).toBe(31) + }) + + // Why: xterm ignores CSI u while the protocol is withheld (ConPTY), so its mirror must too. + it('keeps a withheld-protocol pane at 0 in both records despite host flags', async () => { + const { pane, mirror } = await reattachWithSnapshotFlags(false) + + expect(pane.terminal.write).toHaveBeenCalledWith( + replayEpilogue(POST_REPLAY_REATTACH_RESET), + expect.any(Function) + ) + expect(mirror?.flags).toBe(0) + }) + + it('grounds xterm and the mirror with one scanned write before a restart-in-place spawns', async () => { + const { connectPanePty } = await import('./pty-connection') + transportFactoryQueue.push(createMockTransport()) + const pane = createPane(91) + // Why: a unique tab id keeps this pane's key clear of other tests' pendingSpawnByPaneKey entries so the connect deterministically fresh-spawns. + const deps = createDeps({ tabId: 'tab-kitty-fresh-spawn' }) + // The pane's previous occupant left the alternate screen and kitty flags on. + const mirror = new TerminalKittyKeyboardModeTracker() + mirror.scan('\x1b[?1049h\x1b[>5u') + deps.paneKittyKeyboardModesRef.current.set(pane.id, mirror) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pane-connection mocks cover the members connectPanePty reads. + connectPanePty(pane as never, createManager(91) as never, deps as never) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith(PROCESS_BOUNDARY_GROUND, expect.any(Function)) + expect(mirror.snapshotFlags).toBe(0) + expect(mirror.isAlternateScreen).toBe(false) + }) + + it('grounds xterm and the mirror together on Reset Terminal and asks the host to ground', async () => { + const { pane, mirror } = await reattachWithSnapshotFlags(true) + expect(mirror?.flags).toBe(31) + const { resetTerminalInputModes } = await import('./terminal-input-mode-reset') + + resetTerminalInputModes('tab-pty') + + expect(pane.terminal.write).toHaveBeenLastCalledWith( + PROCESS_BOUNDARY_GROUND, + expect.any(Function) + ) + expect(mirror?.flags).toBe(0) + expect(window.api.pty.resetInputModes).toHaveBeenCalledWith('tab-pty') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts index 39191d5d36a..84043771908 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts @@ -4,6 +4,7 @@ import { POST_REPLAY_REATTACH_RESET, RESET_GRAPHIC_RENDITION } from '../../../../shared/terminal-mode-reset-profiles' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' import type { SshConnectionState } from '../../../../shared/ssh-types' import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' @@ -209,7 +210,7 @@ describe('connectPanePty', () => { // Why: the relay's replay buffer holds full history, so clear xterm before writing to avoid duplicating prior-session content. expect(writes).toContain(`${RESET_GRAPHIC_RENDITION}\x1b[2J\x1b[3J\x1b[H`) expect(writes).toContain('restored-ssh-output') - expect(writes).toContain(POST_REPLAY_REATTACH_RESET) + expect(writes).toContain(replayEpilogue(POST_REPLAY_REATTACH_RESET)) expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH') }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts index 99c54dad2eb..6c22586b59f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-mode-reset.test.ts @@ -7,9 +7,9 @@ import { POST_REPLAY_REATTACH_RESET, POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, RESET_GRAPHIC_RENDITION, - RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' import { flushAsyncTicks } from './pty-connection-test-async' import { withMockedDocumentActiveElement, @@ -275,8 +275,8 @@ describe('connectPanePty', () => { transport.sendInputImmediate.mockClear() sendTerminalInputThroughPane(pane, 'yes') sendTerminalInputThroughPane(pane, '\x1b[A') // arrow-key auto-repeat stays batched - expect(transport.sendInput).toHaveBeenCalledWith('yes') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A') + expect(transport.sendInput).toHaveBeenCalledWith('yes', 'query-reply') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A', 'query-reply') expect(transport.sendInputImmediate).not.toHaveBeenCalled() // terminal-query-reply.test proves real xterm emits this as one framed onData reply; this pins it to the immediate path. @@ -291,7 +291,7 @@ describe('connectPanePty', () => { const printableInputs = [']10;hello', '>|xterm.js(6.1.0-beta.287)', ']|literal-text'] for (const data of printableInputs) { sendTerminalInputThroughPane(pane, data) - expect(transport.sendInput).toHaveBeenCalledWith(data) + expect(transport.sendInput).toHaveBeenCalledWith(data, 'query-reply') } expect(transport.sendInputImmediate).not.toHaveBeenCalled() }) @@ -368,10 +368,10 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(20) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') // Snapshot ends with ?25l (Cursor Agent parks/hides the cursor); the reset must preserve it, not force ?25h, or a stray block paints. expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + replayEpilogue(RESET_TERMINAL_CURSOR_STYLE), expect.any(Function) ) const writes = (pane.terminal.write as ReturnType).mock.calls.map( @@ -411,11 +411,11 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_DEAD_TUI_RESET, + replayEpilogue(POST_REPLAY_DEAD_TUI_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), expect.any(Function) ) }) @@ -459,8 +459,8 @@ describe('connectPanePty', () => { expect(writes).toContain( `${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ` ) - expect(writes).toContain(POST_REPLAY_MODE_RESET) - expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) + expect(writes).not.toContain(replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)) }) }) @@ -512,11 +512,11 @@ describe('connectPanePty', () => { ) const output = writes.join('') const snapshotIndex = output.indexOf('\x1b[?1003h\x1b[?1006h\x1b[?2004huser@host ~ $ ') - const resetIndex = output.indexOf(POST_REPLAY_MODE_RESET) + const resetIndex = output.indexOf(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) expect(snapshotIndex).toBeGreaterThanOrEqual(0) expect(resetIndex).toBeGreaterThan(snapshotIndex) - expect(writes).toContain(POST_REPLAY_MODE_RESET) - expect(writes).not.toContain(POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + expect(writes).toContain(replayEpilogue(POST_REPLAY_MODE_RESET, 0)) + expect(writes).not.toContain(replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET)) }) }) @@ -547,7 +547,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), expect.any(Function) ) }) @@ -581,7 +581,7 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + replayEpilogue(RESET_TERMINAL_CURSOR_STYLE), expect.any(Function) ) }) @@ -614,11 +614,11 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, + replayEpilogue(POST_REPLAY_REATTACH_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), expect.any(Function) ) }) @@ -650,7 +650,8 @@ describe('connectPanePty', () => { .map((call) => String(call[0])) .find( (data) => - data === POST_REPLAY_REATTACH_RESET || data === POST_REPLAY_REATTACH_RESET_KEEP_MOUSE + data === replayEpilogue(POST_REPLAY_REATTACH_RESET) || + data === replayEpilogue(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE) ) }) } @@ -658,12 +659,12 @@ describe('connectPanePty', () => { it('keeps mouse reporting when a reattach snapshot restores a live alternate-screen TUI', async () => { await expect( reattachSnapshotResetFor('\x1b[?1049h\x1b[?1002h\x1b[?1006hthird-party tui session') - ).resolves.toBe(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE) + ).resolves.toBe(replayEpilogue(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE)) }) it('still disarms mouse reporting when a reattach snapshot ends on the normal buffer', async () => { await expect(reattachSnapshotResetFor('\x1b[?1003h\x1b[?1006hdead tui residue')).resolves.toBe( - POST_REPLAY_REATTACH_RESET + replayEpilogue(POST_REPLAY_REATTACH_RESET) ) }) @@ -703,11 +704,11 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, + replayEpilogue(POST_REPLAY_REATTACH_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), expect.any(Function) ) }) @@ -741,11 +742,11 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, + replayEpilogue(POST_REPLAY_REATTACH_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), expect.any(Function) ) }) @@ -782,11 +783,11 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, + replayEpilogue(POST_REPLAY_REATTACH_RESET), expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), expect.any(Function) ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts index 559060108ee..dbcc67fa47e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts @@ -3,9 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_REATTACH_RESET, - RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from '../../../../shared/terminal-mode-reset-profiles' +import { replayEpilogue } from './pty-connection-test-replay-epilogue' import { flushAsyncTicks } from './pty-connection-test-async' import { NORMAL_BUFFER_PROLOGUE, @@ -397,10 +397,10 @@ describe('connectPanePty', () => { await flushAsyncTicks(12) expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), expect.any(Function) ) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') return connection }) disposable.dispose() @@ -437,7 +437,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(12) expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + replayEpilogue(POST_REPLAY_LIVE_AGENT_REATTACH_RESET), expect.any(Function) ) return connection @@ -514,7 +514,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(12) expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + replayEpilogue(RESET_TERMINAL_CURSOR_STYLE), expect.any(Function) ) expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?25h\x1b[?1004l', expect.any(Function)) @@ -673,7 +673,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(8) const replayIndex = writes.indexOf('authoritative replay') - const resetIndex = writes.indexOf(POST_REPLAY_REATTACH_RESET) + const resetIndex = writes.indexOf(replayEpilogue(POST_REPLAY_REATTACH_RESET)) const liveIndex = writes.indexOf('NEWER-LIVE\r\n') expect(replayIndex).toBeGreaterThan(0) expect(resetIndex).toBeGreaterThan(replayIndex) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts index c61fb188caf..92438968984 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts @@ -600,7 +600,7 @@ describe('connectPanePty', () => { writes.join('').indexOf('NEWER-LIVE-SSH-OUTPUT') ) expect(transport.sendInput).not.toHaveBeenCalledWith('codex resume provider-session\r') - expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r') + expect(transport.sendInput).toHaveBeenCalledWith('AFTER_ADOPTION_REPLAY\r', 'query-reply') expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, stablePtyId) }) @@ -642,7 +642,7 @@ describe('connectPanePty', () => { setFitOverride('pty-live', 'remote-desktop-fit', 80, 24) transport.claimViewport.mockClear() ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') expect(transport.claimViewport).toHaveBeenCalledTimes(1) ;(onDataHandler as (data: string) => void)('b') // The renderer stays parked until runtime convergence is acknowledged, so a second keystroke can retry a transient failed resize. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts index 2538c9e0cec..59fdc6decd7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts @@ -222,7 +222,10 @@ describe('connectPanePty', () => { expect(createdTransportOptions[0]?.commandDelivery).toBe('provider') expect(transport.sendInput).not.toHaveBeenCalledWith('droid\r') - expect(transport.sendInputAccepted).toHaveBeenCalledWith(`\x1b[200~${prompt}\x1b[201~`) + expect(transport.sendInputAccepted).toHaveBeenCalledWith( + `\x1b[200~${prompt}\x1b[201~`, + 'launch' + ) }) it('waits past 8s for a cold Codex composer and preserves input ordering', async () => { @@ -279,7 +282,8 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(transport.sendInputAccepted).toHaveBeenCalledWith( - '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' + '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~', + 'launch' ) expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual([ '\x1b[I', @@ -354,7 +358,8 @@ describe('connectPanePty', () => { expect(transport.sendInputAccepted).toHaveBeenCalledTimes(1) expect(transport.sendInputAccepted).toHaveBeenCalledWith( - '\x1b[200~Linked Linear issue: STA-905\x1b[201~' + '\x1b[200~Linked Linear issue: STA-905\x1b[201~', + 'launch' ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts index d4c2ffd6745..a0f06268e97 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts @@ -1,6 +1,9 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles' +import { + buildKittyKeyboardRestore, + RESET_AFTER_BYTE_GAP +} from '../../../../shared/terminal-mode-reset-profiles' import { flushAsyncTicks, createDeferred } from './pty-connection-test-async' import { NORMAL_BUFFER_PROLOGUE } from './pty-connection-test-constants' import { @@ -18,6 +21,9 @@ import { restoreTerminalTestGlobals } from './pty-connection-test-environment' +// An abandon re-asserts the (known-zero) mirror's kitty flags after grounding the gap. +const ABANDONED_RESTORE_GAP = `${RESET_AFTER_BYTE_GAP}${buildKittyKeyboardRestore(0)}` + const { resetAndRefreshAllTerminalWebglAtlases, scheduleTerminalWebglAtlasRecovery, @@ -206,10 +212,10 @@ describe('connectPanePty', () => { const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable')) const combinedLiveIndex = written.indexOf(firstLive + secondLive) expect(warningIndex).toBeGreaterThanOrEqual(0) - expect(written[warningIndex - 1]).toBe(RESET_AFTER_BYTE_GAP) + expect(written[warningIndex - 1]).toBe(ABANDONED_RESTORE_GAP) // Exactly one: writeRestoreUnavailableWarning already grounds the gap, so a // second unconditional write here was pure duplication. - expect(written.filter((data) => data === RESET_AFTER_BYTE_GAP)).toHaveLength(1) + expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1) expect(combinedLiveIndex).toBeGreaterThan(warningIndex) snapshot.resolve({ @@ -276,7 +282,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(10) const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const resetIndex = written.indexOf(RESET_AFTER_BYTE_GAP) + const resetIndex = written.indexOf(ABANDONED_RESTORE_GAP) const liveIndex = written.findIndex((data) => data.includes('live-after-reveal')) expect(resetIndex).toBeGreaterThanOrEqual(0) expect(liveIndex).toBeGreaterThanOrEqual(0) @@ -319,13 +325,13 @@ describe('connectPanePty', () => { await flushAsyncTicks(10) const written = pane.terminal.write.mock.calls.map(([data]) => data as string) - const resetIndex = written.indexOf(RESET_AFTER_BYTE_GAP) + const resetIndex = written.indexOf(ABANDONED_RESTORE_GAP) const liveIndex = written.indexOf(live) expect(resetIndex).toBeGreaterThanOrEqual(0) expect(liveIndex).toBeGreaterThan(resetIndex) // The re-arm arm grounds in rearmRemoteHiddenOutputRestoreInsteadOfWarning, // so the abandon body must not ground a second time. - expect(written.filter((data) => data === RESET_AFTER_BYTE_GAP)).toHaveLength(1) + expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1) expect(written.join('')).not.toContain('main recovery was unavailable') disposable.dispose() @@ -383,8 +389,8 @@ describe('connectPanePty', () => { expect(liveIndex).toBeGreaterThan(warningIndex) // This arm gives up on recovery too, so the gap is grounded exactly once // before the blocked foreground is drained under it. - expect(written.filter((data) => data === RESET_AFTER_BYTE_GAP)).toHaveLength(1) - expect(written.indexOf(RESET_AFTER_BYTE_GAP)).toBeLessThan(liveIndex) + expect(written.filter((data) => data === ABANDONED_RESTORE_GAP)).toHaveLength(1) + expect(written.indexOf(ABANDONED_RESTORE_GAP)).toBeLessThan(liveIndex) disposable.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts index 969bf836594..f85917e28c6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-startup-command-delivery.test.ts @@ -525,7 +525,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(pane.terminal.paste).toHaveBeenCalledWith(command) - expect(transport.sendInput).toHaveBeenCalledWith('\r') + expect(transport.sendInput).toHaveBeenCalledWith('\r', 'launch') expect(transport.sendInput).not.toHaveBeenCalledWith(`${command}\r`) } finally { globalThis.setTimeout = originalSetTimeout diff --git a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts index 4659fccb5c5..4a2d2ee1206 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts @@ -191,7 +191,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('uses the current worktree tab for Codex stale fallback without enumerating all worktrees', async () => { @@ -234,7 +234,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('blocks stale Codex fallback input from the current worktree tab without enumerating all worktrees', async () => { @@ -313,7 +313,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('restores input through the tab fallback when the dismissed pane has no live PTY binding', async () => { @@ -347,7 +347,7 @@ describe('connectPanePty', () => { } ;(onDataHandler as (data: string) => void)('a') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps a dismissed split pane typing while a sibling still holds the prompt', async () => { @@ -389,7 +389,7 @@ describe('connectPanePty', () => { ;(onDataHandler as (data: string) => void)('a') expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-dismissed') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps blocking a pane with its own unanswered notice next to a dismissed sibling', async () => { @@ -466,7 +466,7 @@ describe('connectPanePty', () => { ;(onDataHandler as (data: string) => void)('a') expect((transport.getPtyId as unknown as () => string | null)()).toBe('pty-plain-shell') - expect(transport.sendInput).toHaveBeenCalledWith('a') + expect(transport.sendInput).toHaveBeenCalledWith('a', 'query-reply') }) it('keeps blocking input on a pane whose restart is requested but not yet run', async () => { @@ -782,7 +782,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() vi.advanceTimersByTime(500) - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03') + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') expect(transport.sendInput).not.toHaveBeenCalled() expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) @@ -843,7 +843,7 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalledWith('\r') // The terminator disarmed it, so the next real command reaches the shell. sendTerminalInputThroughPane(pane, 'ls\r') - expect(transport.sendInput).toHaveBeenCalledWith('ls\r') + expect(transport.sendInput).toHaveBeenCalledWith('ls\r', 'query-reply') _resetTerminalPaneRecoveryForTests() }) @@ -867,7 +867,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) await flushAsyncTicks() - expect(transport.sendInput).toHaveBeenCalledWith('x') + expect(transport.sendInput).toHaveBeenCalledWith('x', 'query-reply') expect(pane.terminal.write).toHaveBeenCalledWith('', expect.any(Function)) expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1', AUTOMATIC_REQUEST) binding.dispose() @@ -892,7 +892,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) await flushAsyncTicks() - expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03') + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03', 'query-reply') expect(pane.terminal.write).not.toHaveBeenCalledWith('', expect.any(Function)) binding.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts index eb3a40b6803..d8a9333c359 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts @@ -54,6 +54,8 @@ export function installTerminalTestGlobals(): void { setPtyDeliveryInterest: vi.fn(), ackColdRestore: vi.fn(), onClearBufferRequest: vi.fn(() => vi.fn()), + onResetInputModesRequest: vi.fn(() => vi.fn()), + resetInputModes: vi.fn(), onSerializeBufferRequest: vi.fn(() => vi.fn()), sendSerializedBuffer: vi.fn(), declarePendingPaneSerializer: vi.fn().mockResolvedValue(1), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts index c91c51f789e..302efe743bb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts @@ -103,6 +103,7 @@ export type MockPaneTerminal = { scrollback: number ignoreBracketedPasteMode: boolean theme: { foreground: string; background: string } + vtExtensions: { kittyKeyboard: boolean } } write: Mock<(data: string, callback?: () => void) => void> resize: Mock<(cols: number, rows: number) => void> @@ -162,7 +163,8 @@ export function createPane(paneId: number): MockPane { theme: { foreground: '#eeeeee', background: '#111111' - } + }, + vtExtensions: { kittyKeyboard: true } }, write: vi.fn<(data: string, callback?: () => void) => void>(function write(...args): void { const [data, callback] = args diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-replay-epilogue.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-replay-epilogue.ts new file mode 100644 index 00000000000..56b4c515249 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-replay-epilogue.ts @@ -0,0 +1,6 @@ +import { buildKittyKeyboardRestore } from '../../../../shared/terminal-mode-reset-profiles' + +/** A post-replay reset: the profile, then the mirror's kitty restore (a bare pop while unproven). */ +export function replayEpilogue(profile: string, provenFlags?: number): string { + return `${profile}${buildKittyKeyboardRestore(provenFlags)}` +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts index 119e61e121e..73105c0248a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-typed-agent-identity.test.ts @@ -182,7 +182,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ @@ -436,7 +436,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', @@ -474,7 +474,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('pi\r') + expect(transport.sendInput).toHaveBeenCalledWith('pi\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ @@ -525,7 +525,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', @@ -564,7 +564,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(transport.sendInput).toHaveBeenCalledWith('omp\r', 'query-reply') expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', @@ -650,7 +650,7 @@ describe('connectPanePty', () => { agentType: 'pi' }) - expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r') + expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r', 'query-reply') expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'done', agentType: 'omp', diff --git a/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts index 471e237a200..22ef4f6618a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-windows-keyboard-reset.test.ts @@ -182,7 +182,7 @@ describe('connectPanePty', () => { ) }) - it('resets stale keyboard state when a native Windows agent becomes idle', async () => { + it('keeps kitty keyboard state when a native Windows agent becomes idle', async () => { const restoreUserAgent = temporarilySetNavigatorUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' ) @@ -206,7 +206,12 @@ describe('connectPanePty', () => { idleHandler('* Codex done') + // Why: a finished turn is not a dead app; its kitty flags stay until the host sees it exit. expect(pane.terminal.write).toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, expect.any(Function) ) @@ -254,7 +259,7 @@ describe('connectPanePty', () => { ) transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -311,14 +316,14 @@ describe('connectPanePty', () => { ) transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') } finally { restoreUserAgent() } } ) - it('resets stale keyboard state when native Windows hook status reaches done', async () => { + it('resets cursor style when native Windows hook status reaches done', async () => { const restoreUserAgent = temporarilySetNavigatorUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' ) @@ -359,7 +364,7 @@ describe('connectPanePty', () => { notifyStoreSubscribers() expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + RESET_TERMINAL_CURSOR_STYLE, expect.any(Function) ) } finally { @@ -367,7 +372,7 @@ describe('connectPanePty', () => { } }) - it('resets stale keyboard state when a batched done→working→done burst lands as one publication', async () => { + it('resets cursor style when a batched done→working→done burst lands as one publication', async () => { const restoreUserAgent = temporarilySetNavigatorUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' ) @@ -413,7 +418,7 @@ describe('connectPanePty', () => { notifyStoreSubscribers() expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + RESET_TERMINAL_CURSOR_STYLE, expect.any(Function) ) } finally { @@ -421,7 +426,7 @@ describe('connectPanePty', () => { } }) - it('resets stale keyboard state when a batched burst ends on working after a completed turn', async () => { + it('resets cursor style when a batched burst ends on working after a completed turn', async () => { const restoreUserAgent = temporarilySetNavigatorUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' ) @@ -464,7 +469,7 @@ describe('connectPanePty', () => { notifyStoreSubscribers() expect(pane.terminal.write).toHaveBeenCalledWith( - `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + RESET_TERMINAL_CURSOR_STYLE, expect.any(Function) ) } finally { @@ -472,7 +477,7 @@ describe('connectPanePty', () => { } }) - it('keeps native Windows same-turn done repaints from re-resetting keyboard state', async () => { + it('keeps native Windows same-turn done repaints from re-resetting cursor style', async () => { const restoreUserAgent = temporarilySetNavigatorUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' ) @@ -568,7 +573,7 @@ describe('connectPanePty', () => { notifyStoreSubscribers() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -621,8 +626,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[O') sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I', 'query-reply') } finally { restoreUserAgent() } @@ -667,8 +672,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x7f') sendTerminalInputThroughPane(pane, 'x') - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x7f') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, 'x') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x7f', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, 'x', 'query-reply') expect(pane.terminal.modes.sendFocusMode).toBe(true) mockStoreState.agentStatusByPaneKey[paneKey] = { @@ -679,7 +684,7 @@ describe('connectPanePty', () => { transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I', 'query-reply') expect(pane.terminal.modes.sendFocusMode).toBe(true) mockStoreState.agentStatusByPaneKey[paneKey] = { @@ -689,7 +694,7 @@ describe('connectPanePty', () => { notifyStoreSubscribers() transport.sendInput.mockClear() sendTerminalInputThroughPane(pane, '\x1b[O') - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O') + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O', 'query-reply') } finally { restoreUserAgent() } @@ -725,8 +730,8 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, '\x1b[O') sendTerminalInputThroughPane(pane, '\x1b[I') - expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O') - expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I') + expect(transport.sendInput).toHaveBeenNthCalledWith(1, '\x1b[O', 'query-reply') + expect(transport.sendInput).toHaveBeenNthCalledWith(2, '\x1b[I', 'query-reply') expect(pane.terminal.modes.sendFocusMode).toBe(true) } finally { restoreUserAgent() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index 1eabdf99032..e8f8e7c1a08 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -91,8 +91,8 @@ export function createReattachPayloadHandlers( : daemonSnapshotReplay }` ) - session.writeReplayData( - session.reattachReplayResetSequence( + session.writeReplayEpilogue( + session.chooseReattachReplayReset( daemonSnapshotReplay, Boolean(ctx.connectResult.coldRestore), ctx.connectResult.isAlternateScreen, @@ -198,8 +198,8 @@ export function createReattachPayloadHandlers( })) { session.writeReplayData(replayChunk) } - session.writeReplayData( - session.reattachReplayResetSequence( + session.writeReplayEpilogue( + session.chooseReattachReplayReset( modelData, Boolean(ctx.connectResult?.coldRestore), modelSnapshot.alternateScreen ?? ctx.connectResult?.isAlternateScreen, @@ -237,8 +237,8 @@ export function createReattachPayloadHandlers( session.writeReplayData( `${ctx.connectResult.coldRestore ? RESET_GRAPHIC_RENDITION : ''}${ctx.connectResult.replay}` ) - session.writeReplayData( - session.reattachReplayResetSequence( + session.writeReplayEpilogue( + session.chooseReattachReplayReset( ctx.connectResult.replay, Boolean(ctx.connectResult.coldRestore), ctx.connectResult.isAlternateScreen @@ -305,9 +305,7 @@ export function createReattachPayloadHandlers( session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) } // Why: cold-restore spawned a fresh shell; reset mode bytes a crashed TUI (e.g. Claude's \e[?1004h) left in scrollback that no live TUI now consumes. - session.writeReplayData(POST_REPLAY_MODE_RESET) - // Why: the dead run's kitty flags died with it and its scrollback was never scanned — the fresh shell starts at zero. - session.kittyKeyboardModes.reset() + session.writeInputModeGround(POST_REPLAY_MODE_RESET) session.consumeRestoredViewportBlankingMarker() // Why: a taller destination fit must not pull recovered rows back into the fresh shell's viewport after source-grid replay. session.writeFreshShellViewportBlanking(Math.max(destinationRows, session.pane.terminal.rows)) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts index c6a25e0d461..417ccd812da 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty-session.ts @@ -1,5 +1,6 @@ import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyConnectionDeps } from '../pty-connection-types' +import type { PtyTransport } from '../pty-transport-types' // Why: connectPanePty is a single closure factory. The bag lets extracted // installers share the same mutable bindings without changing call order. @@ -7,6 +8,8 @@ export type ConnectPanePtySession = { pane: ManagedPane manager: PaneManager deps: PtyConnectionDeps + // Why typed: every PTY write must name its input kind, and the bag's `any` would hide a missing one. + transport: PtyTransport // oxlint-disable-next-line typescript/no-explicit-any -- session bag for mechanical extract [key: string]: any } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 1c43f40b0b4..8c560ce30bd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -114,10 +114,6 @@ export function connectPanePty( session.remoteOutputGatedPtyId = null session.remoteOutputFactConsumerPtyId = null session.suppressViewportClaimTerminalResize = false - // Why: idle callbacks are registered before the deferred PTY output plumbing - // exists. Start with the shared scheduler, then switch to the PTY writer - // below so hidden-tab resets keep backlog-recovery callbacks and byte order. - session.idleAgentTerminalModeReset = RESET_TERMINAL_CURSOR_STYLE session.suppressNativeWindowsIdleCodexFocusReports = false session.setFocusReportSuppressionForAgentCompletion = ( title: string | undefined, @@ -127,11 +123,14 @@ export function connectPanePty( session.suppressNativeWindowsIdleCodexFocusReports = agentType && agentType !== 'unknown' ? agentType === 'codex' : titleAgentType === 'codex' } + // Why: idle callbacks are registered before the deferred PTY output plumbing + // exists. Start with the shared scheduler, then switch to the PTY writer + // so hidden-tab resets keep backlog-recovery callbacks and byte order. session.queueAgentIdleTerminalModeReset = (): void => { if (session.disposed) { return } - writeTerminalOutput(session.pane.terminal, session.idleAgentTerminalModeReset, { + writeTerminalOutput(session.pane.terminal, RESET_TERMINAL_CURSOR_STYLE, { foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) }) } @@ -162,15 +161,16 @@ export function connectPanePty( // Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay. // Use the stable layout leaf UUID, not the renderer-local numeric pane id. session.cacheKey = makePaneKey(session.deps.tabId, session.pane.leafId) - // Why: mirrors the kitty keyboard flags the pane's application negotiates, - // so it still reflects what a live application expects after the renderer's - // defensive kitty wipes; see TerminalKittyKeyboardModeTracker for its feeds. + // Why: xterm exposes no kitty read, so this mirror tracks the flags xterm's + // encoder applies; see TerminalKittyKeyboardModeTracker for its feeds. session.kittyKeyboardModes = (() => { const existing = session.deps.paneKittyKeyboardModesRef.current.get(session.pane.id) if (existing) { return existing } - const created = new TerminalKittyKeyboardModeTracker() + const created = new TerminalKittyKeyboardModeTracker({ + kittyKeyboard: session.pane.terminal.options.vtExtensions?.kittyKeyboard === true + }) session.deps.paneKittyKeyboardModesRef.current.set(session.pane.id, created) return created })() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 6c3a0628f79..f2a91feb98c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -91,7 +91,7 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) { return false } - return session.transport.sendInput('\r') + return session.transport.sendInput('\r', 'launch') } session.schedulePendingStartupCommandDelivery = (): void => { const startup = session.pendingStartupCommand @@ -116,7 +116,7 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio const command = startup.command const submitted = session.shouldDeliverStartupViaTerminalPaste ? await runTerminalPasteStartupCommand(command) - : session.transport.sendInput(`${command}\r`) + : session.transport.sendInput(`${command}\r`, 'launch') if (submitted) { session.armStartupDraftReadinessObservation() } else { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts index 67fd09bcc59..a494d08fc80 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts @@ -6,10 +6,6 @@ import { hasCachedWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { requestTerminalWritePipelineProbe } from '@/lib/pane-manager/terminal-write-pipeline-health' -import { - RESET_KITTY_KEYBOARD_PROTOCOL, - RESET_TERMINAL_CURSOR_STYLE -} from '../../../../../shared/terminal-mode-reset-profiles' import { subscribeToTerminalUserInput } from '../terminal-user-input-signal' import { isLocalNativeWindowsConpty, @@ -86,11 +82,6 @@ export function installDirectSshRetryStatus(session: ConnectPanePtySession): voi ), executionHostId: session.executionHostId }) - if (session.isNativeWindowsConpty) { - // Why: Windows ConPTY agent turns can leave renderer keyboard modes armed - // after completion, corrupting plain input with encoded bytes. - session.idleAgentTerminalModeReset = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}` - } session.shouldApplyNativeWindowsRewriteRefresh = session.isNativeWindowsConpty session.shouldApplyWindowsRendererUnicodeRefresh = CLIENT_PLATFORM === 'win32' session.shouldProtectNativeWindowsSynchronizedOutput = session.isNativeWindowsConpty diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts index dff163f81d8..9c1b45051be 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-refresh.ts @@ -151,6 +151,7 @@ export function bindForegroundOutputRefresh(session: ConnectPanePtySession): voi session.hiddenDeliverySyncedPtyId = null } if ( + !ptyId || !session.isHiddenDeliveryGateManagedPty(ptyId) || !session.canUseHiddenOutputSnapshot(ptyId) ) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts index 83ab2e3f478..cb553ebc7de 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts @@ -5,7 +5,8 @@ import { POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, - POST_REPLAY_REATTACH_RESET_KEEP_MOUSE + POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, + buildKittyKeyboardRestore } from '../../../../../shared/terminal-mode-reset-profiles' import { buildFreshShellViewportBlankingSequence } from '../terminal-restored-viewport' import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' @@ -129,7 +130,29 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void }) } - session.reattachReplayResetSequence = ( + // Why one writer: the kitty mirror and xterm must parse every renderer-originated mode byte alike. + session.writeInputModeGround = (data: string): void => { + session.kittyKeyboardModes.scan(data) + session.writeReplayData(data) + } + + /** + * Writes a post-replay reset ending in the mirror's Kitty flags, through the + * mirror first. The restore is built after scanning the profile because its + * `?1049l` moves both records to the other screen's slot. + */ + session.writeReplayEpilogue = ( + profile: string, + write: (data: string) => T = session.writeReplayData + ): T => { + session.kittyKeyboardModes.scan(profile) + const kitty = buildKittyKeyboardRestore(session.kittyKeyboardModes.snapshotFlags) + session.kittyKeyboardModes.scan(kitty) + return write(`${profile}${kitty}`) + } + + /** Picks the post-replay reset profile for a reattach; the caller writes it via writeReplayEpilogue. */ + session.chooseReattachReplayReset = ( payload: string, ownerProcessEnded = false, isAlternateScreen?: boolean, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 29bc438a01c..b260adcccb8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store' +import { PROCESS_BOUNDARY_GROUND } from '../../../../../shared/terminal-mode-reset-profiles' import { hasPtySerializer } from '../pty-buffer-serializer' import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' @@ -52,11 +53,9 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { // Why: a canceled old replay clear can preserve xterm's native // isUserScrolling flag. A replacement shell must start in follow mode. session.resetFreshSpawnFollowOutput() - // Why: a fresh spawn is a new process with kitty keyboard flags at - // zero. The exit-handler reset alone is not enough: a late exit from a - // replaced PTY takes the stale-transport early return and skips it, so - // a restart-in-place would leak the old TUI's flags into a fresh shell. - session.kittyKeyboardModes.reset() + // Why: a fresh spawn is a new process, so a restart-in-place must not + // inherit the old TUI's screen, mouse or kitty modes in xterm or the mirror. + session.writeInputModeGround(PROCESS_BOUNDARY_GROUND) session.prepareFreshShellViewportForSpawn(options) const coldRestoreOverride = startupOverride && 'launchConfig' in startupOverride diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts index 3d08e5d495b..3e30289c2f6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-abandon.ts @@ -1,4 +1,3 @@ -import { RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles' import { cancelScheduledHiddenOutputRestore } from '../hidden-output-restore-scheduler' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -62,7 +61,7 @@ export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession): // session.rearmRemoteHiddenOutputRestoreInsteadOfWarning. Only the quiet // flood-abandon reaches neither, and it still drains chunks below. else if (!rearmedRemoteRestore) { - session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) + session.writeAbandonedRestoreGap() } if (hadPendingOverflow) { return diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts index 1705fbe2cdf..ea4c48f43f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-drain.ts @@ -1,6 +1,5 @@ import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs' import { redactPtyIdForDiagnostics } from '../../../../../shared/pty-delivery-diagnostics' -import { RESET_AFTER_BYTE_GAP } from '../../../../../shared/terminal-mode-reset-profiles' import { cancelScheduledHiddenOutputRestore } from '../hidden-output-restore-scheduler' import { isRemoteExecutionHostPtyId } from '../remote-execution-host-pty' @@ -173,7 +172,7 @@ export function bindHiddenOutputRestoreDrain(session: ConnectPanePtySession): vo cycle: session.hiddenOutputRestoreRemoteAbandonCycles }) session.noteHiddenOutputRestoreFloodBackpressure() - session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) + session.writeAbandonedRestoreGap() return true } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index 450fdd799db..e895128b280 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -6,7 +6,8 @@ import { POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_DEAD_TUI_RESET, POST_REPLAY_REATTACH_RESET, - RESET_AFTER_BYTE_GAP + RESET_AFTER_BYTE_GAP, + buildKittyKeyboardRestore } from '../../../../../shared/terminal-mode-reset-profiles' import { discardTerminalOutput, @@ -30,9 +31,18 @@ import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs' import type { ConnectPanePtySession } from './connect-pane-pty-session' export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): void { + // Why the kitty restore: an abandoned restore discards queued chunks the + // mirror already scanned, so xterm re-adopts the mirror's flags. + session.writeAbandonedRestoreGap = function (): void { + session.writePtyOutputToXterm( + `${RESET_AFTER_BYTE_GAP}${buildKittyKeyboardRestore(session.kittyKeyboardModes.snapshotFlags)}`, + true + ) + } + session.writeRestoreUnavailableWarning = function (): void { // The reset must parse before both the warning and any foreground drain. - session.writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true) + session.writeAbandonedRestoreGap() if (!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)) { return } @@ -156,7 +166,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): : hasLiveAgent ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET : POST_REPLAY_LIVE_SNAPSHOT_RESET - session.writeReplayData(postReplayReset) + session.writeReplayEpilogue(postReplayReset) if (snapshot.pendingEscapeTailAnsi) { // Why last: snapshot taken mid-escape; re-arm as the FINAL replay write (any later ESC aborts it) so the live tail completes it, not render literally (Bug E / #7329). session.writeReplayData(snapshot.pendingEscapeTailAnsi) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 97ce140bd05..2c86932c499 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -1,9 +1,7 @@ import { agentTypeToIconAgent } from '@/lib/agent-status' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' -import { replayIntoTerminal } from '../replay-guard' -import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { POST_REPLAY_REATTACH_RESET } from '../../../../../shared/terminal-mode-reset-profiles' +import { CONFIRMED_SHELL_MODE_RESET } from '../../../../../shared/terminal-mode-reset-profiles' import { isLocalNativeWindowsConpty, resolveWindowsShellOverride @@ -182,18 +180,7 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { // Why: a hard-killed agent leaves mouse/focus/kitty modes armed, and the // surviving shell then receives pointer moves as typed SGR reports; the // replay guard keeps xterm's auto-replies from leaking to the shell. - // The input mirror scans the same bytes so shortcuts stop encoding for the dead app; - // draining queued output first keeps both in stream order. - flushTerminalOutput(session.pane.terminal) - session.kittyKeyboardModes.scan(POST_REPLAY_REATTACH_RESET) - replayIntoTerminal(session.pane, session.deps.replayingPanesRef, POST_REPLAY_REATTACH_RESET, { - breadcrumbIdentity: { - tabId: session.deps.tabId, - worktreeId: session.deps.worktreeId, - ptyId: session.transport.getPtyId() - }, - shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously - }) + session.writeInputModeGround(CONFIRMED_SHELL_MODE_RESET) if (reason === 'visible-pty') { state.clearAgentLaunchConfig(session.cacheKey) return diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts index b4a953ef5b5..2b75548ec39 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts @@ -1,3 +1,4 @@ +import { buildProcessBoundaryGround } from '../../../../../shared/terminal-mode-reset-profiles' import { serializeWithAbsoluteCursor } from '../../../../../shared/terminal-serialize-absolute-cursor' import { isTerminalWritePipelineCertifiedDead } from '@/lib/pane-manager/terminal-write-pipeline-health' import { registerPtySerializer, registerPtyTitleSource } from '../pty-buffer-serializer' @@ -72,10 +73,16 @@ export function bindRegisterPaneSerializer(session: ConnectPanePtySession): void return null } }, - () => { - session.clearHiddenOutputRestoreState() - discardTerminalOutput(session.pane.terminal) - clearTerminalScrollbackAndFollowOutput(session.pane.terminal) + { + clear: () => { + session.clearHiddenOutputRestoreState() + discardTerminalOutput(session.pane.terminal) + clearTerminalScrollbackAndFollowOutput(session.pane.terminal) + }, + resetInputModes: () => + session.writeInputModeGround( + buildProcessBoundaryGround({ keepFocusReporting: session.isNativeWindowsConpty }) + ) } ) const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) => diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts index 9ca1202e9b1..8015e49dd11 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -122,16 +122,22 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { ) // Why: xterm focus reports share this transport queue. Bypassing it can // race CSI I against the draft on ConPTY and expose a literal `[I` prefix. - void sendAgentDraftPasteContent(settings, ptyId, session.startupDraftPrompt, async (data) => { - const accepted = await writeTerminalPastePtyInput(session.transport, data) - if (accepted && !startupDraftInputRecorded) { - // Why: this transport write bypasses xterm's user-input signal; keep - // the composed draft from being discarded by later hibernation. - startupDraftInputRecorded = true - session.recordTerminalInputForHibernation() + void sendAgentDraftPasteContent( + settings, + ptyId, + session.startupDraftPrompt, + 'launch', + async (data) => { + const accepted = await writeTerminalPastePtyInput(session.transport, data, 'launch') + if (accepted && !startupDraftInputRecorded) { + // Why: this transport write bypasses xterm's user-input signal; keep + // the composed draft from being discarded by later hibernation. + startupDraftInputRecorded = true + session.recordTerminalInputForHibernation() + } + return accepted } - return accepted - }) + ) .catch(() => false) .finally(() => { startupDraftPasteInFlight = false diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts index 3ea62593ed3..98315f2ca6f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts @@ -6,7 +6,6 @@ import { deferPtyShutdownExit, isHostPtySleepPending } from '../pty-shutdown-exit-deferral' -import { replayIntoTerminal } from '../replay-guard' import { POST_REPLAY_MODE_RESET } from '../../../../../shared/terminal-mode-reset-profiles' import { isProvenProcessExit } from '../../../../../shared/terminal-exit-cause' import { getProviderSessionClaimKey } from '@/lib/sleeping-agent-pane-ownership' @@ -225,9 +224,6 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { if (!isUnverifiedExit) { session.clearPanePtyFitBinding() } - // Why: the negotiating application died with its PTY; any replacement - // session starts with kitty keyboard flags at zero. - session.kittyKeyboardModes.reset() const isSuppressedExit = session.deps.consumeSuppressedPtyExit(ptyId) || preserveRendererBinding || @@ -272,14 +268,7 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { // frame still has mouse-tracking/bracketed-paste armed, which silently // eats every click and keystroke against a dead transport — disarm the // modes now and arm the reveal-time wake. - replayIntoTerminal(session.pane, session.deps.replayingPanesRef, POST_REPLAY_MODE_RESET, { - breadcrumbIdentity: { - tabId: session.deps.tabId, - worktreeId: session.deps.worktreeId, - ptyId - }, - shouldRefreshViewportSynchronously: session.shouldRefreshForegroundSynchronously - }) + session.writeInputModeGround(POST_REPLAY_MODE_RESET) session.hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record } const pendingWakeMatches = session.pendingHibernatedWakeTarget?.ptyId === ptyId && diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts index 6ff7c244443..aad2384bb27 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts @@ -100,6 +100,8 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { session.clearPendingTerminalInputIntent() return } + // Why xterm's provenance: its own focus reports reach onData too, and no person typed them. + const inputKind = wasUserInput ? 'driving' : 'query-reply' const intent = session.pendingTerminalInputIntent // Why: real xterm can deliver the terminal byte even when our DOM keydown // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to @@ -123,7 +125,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { } session.clearPendingTerminalInputIntent() const writePromise = session.transport - .sendInputAccepted(data) + .sendInputAccepted(data, inputKind) .then((accepted): boolean | Promise | null => { if (accepted) { // Why: rejected writes use transport recovery and must not arm a parser probe. @@ -152,7 +154,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { } if (intent) { session.claimViewportForUserActivity() - if (session.transport.sendInput(data)) { + if (session.transport.sendInput(data, inputKind)) { session.markAcceptedTerminalInputSent() session.observeAcceptedShellCommandInput(data) session.observeAcceptedTerminalInput(data, intent) @@ -163,7 +165,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { return } session.claimViewportForUserActivity() - if (session.transport.sendInput(data)) { + if (session.transport.sendInput(data, inputKind)) { session.markAcceptedTerminalInputSent() session.observeAcceptedShellCommandInput(data) session.observeAcceptedTerminalInput(data) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts index de3ee9011a8..46d2d41982d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts @@ -10,11 +10,14 @@ import type { ReattachPayloadContext } from './reattach-payload-context' import type { ReattachPayloadSession } from './reattach-payload-session' function createSession(overrides: Record = {}): ReattachPayloadSession { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test read only these session members. return { pane: createPane(1), rememberReattachPayloadAgentSignal: vi.fn(), writeReplayData: vi.fn(), - reattachReplayResetSequence: vi.fn(() => ''), + writeInputModeGround: vi.fn(), + chooseReattachReplayReset: vi.fn(() => ''), + writeReplayEpilogue: vi.fn(), sendFocusedReattachFocusInAfterReplay: vi.fn(), kittyKeyboardModes: { hasProvenBaseline: true, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts index c9d53a56b54..b4c963d29f4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts @@ -5,6 +5,7 @@ export type ReattachPayloadSession = Pick< | 'applyColdRestoreAgentResumeStartup' | 'applySnapshotKittyKeyboardModes' | 'buildColdRestoreAgentResumeStartup' + | 'chooseReattachReplayReset' | 'clearSleepingRecordAfterColdRestoreSpawn' | 'consumeRestoredViewportBlankingMarker' | 'createReattachGridPush' @@ -13,7 +14,6 @@ export type ReattachPayloadSession = Pick< | 'markHiddenOutputRestoreNeeded' | 'pane' | 'pendingReattachFit' - | 'reattachReplayResetSequence' | 'recordRendererOrderedSeq' | 'rememberReattachPayloadAgentSignal' | 'schedulePendingStartupCommandDelivery' @@ -24,5 +24,7 @@ export type ReattachPayloadSession = Pick< | 'suppressStructuralReplayPtyResize' | 'transport' | 'writeFreshShellViewportBlanking' + | 'writeInputModeGround' + | 'writeReplayEpilogue' | 'writeReplayData' > diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts index 68782e666f6..bf14e261b2b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts @@ -36,11 +36,13 @@ function createSession( fireLog: FireLog, overrides: Record = {} ): ReattachPayloadSession { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test read only these session members. return { pane: createPane(1), rememberReattachPayloadAgentSignal: vi.fn(), writeReplayData: vi.fn((data: string) => fireLog.push(`write:${data}`)), - reattachReplayResetSequence: vi.fn(() => ''), + chooseReattachReplayReset: vi.fn(() => ''), + writeReplayEpilogue: vi.fn((profile: string) => fireLog.push(`write:${profile}`)), sendFocusedReattachFocusInAfterReplay: vi.fn(), applySnapshotKittyKeyboardModes: vi.fn(() => fireLog.push('kitty:snapshot-baseline')), setRestoredSnapshotBaseline: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index 81ac85ed752..087326d0f40 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -63,7 +63,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { if (!session.shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { return } - session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE) + session.transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE, 'query-reply') }) } @@ -158,8 +158,9 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { continue } if (clearBeforeReplay || data.length > 0) { - await session.writeReplayDataAsync( - session.reattachReplayResetSequence(data, false, alternateScreen, terminalOwner) + await session.writeReplayEpilogue( + session.chooseReattachReplayReset(data, false, alternateScreen, terminalOwner), + session.writeReplayDataAsync ) if (!isCurrentPayload()) { continue diff --git a/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts b/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts index 8dfe10e8a6e..fd2a4a097a2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/ssh-snapshot-prepaint.ts @@ -60,7 +60,7 @@ export function bindPrepaintParkedSshSnapshot(session: ConnectPanePtySession): v })) { session.writeReplayData(replayChunk) } - session.writeReplayData(session.reattachReplayResetSequence(modelData)) + session.writeReplayEpilogue(session.chooseReattachReplayReset(modelData)) if (snapshot.pendingEscapeTailAnsi) { session.writeReplayData(snapshot.pendingEscapeTailAnsi) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts index 3335776a118..34fdd14bf70 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/write-pty-output-to-xterm.ts @@ -1,6 +1,7 @@ import { takeCurrentTerminalDeliveryCredit } from '@/lib/pane-manager/terminal-delivery-credit' import { nativeWindowsRewriteNeedsFollowupRenderRefresh } from '@/lib/pane-manager/terminal-complex-script' import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { RESET_TERMINAL_CURSOR_STYLE } from '../../../../../shared/terminal-mode-reset-profiles' import { forceFullViewportPresent } from '@/lib/pane-manager/terminal-render-pause-release' import { FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS } from './foreground-output-budgets' @@ -126,7 +127,7 @@ export function bindWritePtyOutputToXterm(session: ConnectPanePtySession): void return } session.writePtyOutputToXterm( - session.idleAgentTerminalModeReset, + RESET_TERMINAL_CURSOR_STYLE, shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) ) } diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts index 6ac6068f068..b3a2236fe1c 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue-contract.ts @@ -1,3 +1,5 @@ +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' + export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES = 64 export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS = @@ -8,6 +10,7 @@ export type PendingPtyInputWrite = { id: string text: string replyOnly: boolean + inputKind: TerminalInputKind resolveAccepted: ((accepted: boolean) => void) | undefined tooLarge: boolean | Promise chunks?: Iterator @@ -15,17 +18,17 @@ export type PendingPtyInputWrite = { } export type PtyInputWriteQueue = { - enqueue: (id: string, data: string) => boolean + enqueue: (id: string, data: string, inputKind: TerminalInputKind) => boolean enqueueQueryReply: (id: string, data: string) => boolean - enqueueAccepted: (id: string, data: string) => Promise + enqueueAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise waitForDrain: () => Promise clear: () => void } export type PtyInputWriteQueueDeps = { isWritable: (id: string) => boolean - write: (id: string, data: string) => void - writeAccepted?: (id: string, data: string) => Promise + write: (id: string, data: string, inputKind: TerminalInputKind) => void + writeAccepted?: (id: string, data: string, inputKind: TerminalInputKind) => Promise yieldBetweenWrites?: () => Promise onDrainFailure?: (id: string) => void } diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts index 556ba19f8a6..9b3099d0326 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -81,7 +81,7 @@ describe('pty input write queue', () => { // Simulates a 2s aggressive trackpad gesture at 120Hz: 240 SGR reports // enqueued while the drain cannot run between events. for (let i = 0; i < 240; i += 1) { - expect(queue.enqueue('pty-1', WHEEL_UP_REPORT)).toBe(true) + expect(queue.enqueue('pty-1', WHEEL_UP_REPORT, 'driving')).toBe(true) } await queue.waitForDrain() @@ -98,7 +98,7 @@ describe('pty input write queue', () => { const inputs = ['a', '\x1b[<65;1;1M', 'bc', '\x1b[A', 'd'] for (const input of inputs) { - queue.enqueue('pty-1', input) + queue.enqueue('pty-1', input, 'driving') } await queue.waitForDrain() @@ -112,10 +112,10 @@ describe('pty input write queue', () => { write: (id, data) => writes.push({ id, data }) }) - queue.enqueue('pty-1', 'a') - queue.enqueue('pty-1', 'b') - queue.enqueue('pty-2', 'c') - queue.enqueue('pty-1', 'd') + queue.enqueue('pty-1', 'a', 'driving') + queue.enqueue('pty-1', 'b', 'driving') + queue.enqueue('pty-2', 'c', 'driving') + queue.enqueue('pty-1', 'd', 'driving') await queue.waitForDrain() expect(writes).toEqual([ @@ -131,7 +131,7 @@ describe('pty input write queue', () => { const piece = 'x'.repeat(1000) for (let i = 0; i < 12; i += 1) { - queue.enqueue('pty-1', piece) + queue.enqueue('pty-1', piece, 'driving') } await queue.waitForDrain() @@ -146,9 +146,9 @@ describe('pty input write queue', () => { const { writes, queue } = createRecordingQueue() const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 100) - queue.enqueue('pty-1', 'before') - queue.enqueue('pty-1', large) - queue.enqueue('pty-1', 'after') + queue.enqueue('pty-1', 'before', 'driving') + queue.enqueue('pty-1', large, 'driving') + queue.enqueue('pty-1', 'after', 'driving') await queue.waitForDrain() expect(writes.map((write) => write.data).join('')).toBe(`before${large}after`) @@ -161,7 +161,7 @@ describe('pty input write queue', () => { it('rejects input over the terminal input byte limit without writing', async () => { const { writes, queue } = createRecordingQueue() - expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1), 'driving')).toBe(false) await queue.waitForDrain() expect(writes).toEqual([]) @@ -171,9 +171,9 @@ describe('pty input write queue', () => { let writable = true const { writes, queue } = createRecordingQueue({ writable: () => writable }) - queue.enqueue('pty-1', 'a') + queue.enqueue('pty-1', 'a', 'driving') writable = false - queue.enqueue('pty-1', 'b') + queue.enqueue('pty-1', 'b', 'driving') await queue.waitForDrain() expect(writes).toEqual([{ id: 'pty-1', data: 'a' }]) @@ -210,7 +210,7 @@ describe('pty input write queue', () => { const reply = mode2031SequenceFor('dark') queue.enqueueQueryReply('pty-1', reply) - queue.enqueue('pty-1', 'y') + queue.enqueue('pty-1', 'y', 'driving') await queue.waitForDrain() expect(writes).toEqual([ @@ -231,7 +231,7 @@ describe('pty input write queue', () => { allAccepted = queue.enqueueQueryReply('pty-1', reply) && allAccepted } expect(allAccepted).toBe(true) - expect(queue.enqueue('pty-1', 'k')).toBe(true) + expect(queue.enqueue('pty-1', 'k', 'driving')).toBe(true) await Promise.resolve() expect(pendingYields).toHaveLength(1) @@ -256,7 +256,7 @@ describe('pty input write queue', () => { for (const reply of replies) { expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) } - expect(queue.enqueue('pty-1', 'k')).toBe(true) + expect(queue.enqueue('pty-1', 'k', 'driving')).toBe(true) await Promise.resolve() for (let turn = 0; turn < PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES; turn += 1) { @@ -286,10 +286,10 @@ describe('pty input write queue', () => { expect(second.length * 3).toBeGreaterThan(PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS) queue.enqueueQueryReply('pty-1', first) queue.enqueueQueryReply('pty-1', dropped) - queue.enqueue('pty-1', 'x') + queue.enqueue('pty-1', 'x', 'driving') queue.enqueueQueryReply('pty-1', second) queue.enqueueQueryReply('pty-1', third) - queue.enqueue('pty-1', 'k') + queue.enqueue('pty-1', 'k', 'driving') await Promise.resolve() expect(pendingYields).toHaveLength(1) @@ -365,7 +365,7 @@ describe('pty input write queue', () => { const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`) for (const input of ordinary) { - expect(queue.enqueue('pty-1', input)).toBe(true) + expect(queue.enqueue('pty-1', input, 'driving')).toBe(true) } for (const reply of replies) { expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) @@ -386,7 +386,7 @@ describe('pty input write queue', () => { const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`) expect(queue.enqueueQueryReply('pty-1', '\x1b[?10000;1n')).toBe(true) - expect(queue.enqueue('pty-1', ordinary)).toBe(true) + expect(queue.enqueue('pty-1', ordinary, 'driving')).toBe(true) for (const reply of replies) { expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true) } @@ -413,14 +413,14 @@ describe('pty input write queue', () => { }) try { - expect(queue.enqueue('pty-1', 'stale')).toBe(true) - expect(queue.enqueue('pty-1', 'also-stale')).toBe(false) + expect(queue.enqueue('pty-1', 'stale', 'driving')).toBe(true) + expect(queue.enqueue('pty-1', 'also-stale', 'driving')).toBe(false) await expect(queue.waitForDrain()).resolves.toBeUndefined() shouldThrow = false - expect(queue.enqueue('pty-1', 'still-stale')).toBe(false) + expect(queue.enqueue('pty-1', 'still-stale', 'driving')).toBe(false) queue.clear() - expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + expect(queue.enqueue('pty-1', 'fresh', 'driving')).toBe(true) await queue.waitForDrain() expect(writes).toEqual([{ id: 'pty-1', data: 'fresh' }]) @@ -443,9 +443,9 @@ describe('pty input write queue', () => { } }) - expect(queue.enqueue('pty-1', 'first')).toBe(true) - const accepted = queue.enqueueAccepted('pty-1', 'second') - expect(queue.enqueue('pty-1', 'third')).toBe(true) + expect(queue.enqueue('pty-1', 'first', 'driving')).toBe(true) + const accepted = queue.enqueueAccepted('pty-1', 'second', 'driving') + expect(queue.enqueue('pty-1', 'third', 'driving')).toBe(true) expect(queue.enqueueQueryReply('pty-1', 'fourth')).toBe(true) await flushAsyncTicks() @@ -475,13 +475,13 @@ describe('pty input write queue', () => { delivered.push(`accepted:${data}`) if (!reentered) { reentered = true - queue.enqueue('pty-1', 'later') + queue.enqueue('pty-1', 'later', 'driving') } return acceptedWrite.promise } }) - const accepted = queue.enqueueAccepted('pty-1', 'first') + const accepted = queue.enqueueAccepted('pty-1', 'first', 'driving') await flushAsyncTicks() expect(delivered).toEqual(['accepted:first']) @@ -503,12 +503,12 @@ describe('pty input write queue', () => { writeAccepted: (_id, data) => { delivered.push(`accepted:${data}`) queue.clear() - queue.enqueue('pty-1', 'fresh') + queue.enqueue('pty-1', 'fresh', 'driving') return acceptedWrite.promise } }) - const accepted = queue.enqueueAccepted('pty-1', 'stale') + const accepted = queue.enqueueAccepted('pty-1', 'stale', 'driving') await expect(accepted).resolves.toBe(false) await queue.waitForDrain() @@ -533,12 +533,12 @@ describe('pty input write queue', () => { return acceptedWrite.promise } }) - const active = queue.enqueueAccepted('pty-1', 'stale-active') - const pending = queue.enqueueAccepted('pty-1', 'stale-pending') + const active = queue.enqueueAccepted('pty-1', 'stale-active', 'driving') + const pending = queue.enqueueAccepted('pty-1', 'stale-pending', 'driving') await acceptedStarted.promise queue.clear() - expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + expect(queue.enqueue('pty-1', 'fresh', 'driving')).toBe(true) await expect(active).resolves.toBe(false) await expect(pending).resolves.toBe(false) @@ -567,7 +567,9 @@ describe('pty input write queue', () => { }) try { - expect(queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2))).toBe(true) + expect( + queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2), 'driving') + ).toBe(true) expect(rejectYield).toBeDefined() rejectYield?.(failure) await queue.waitForDrain() @@ -607,11 +609,13 @@ describe('pty input write queue', () => { }) try { - expect(queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2))).toBe(true) + expect( + queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2), 'driving') + ).toBe(true) expect(rejectYield).toBeDefined() queue.clear() rejectNextYield = false - expect(queue.enqueue('pty-2', 'fresh')).toBe(true) + expect(queue.enqueue('pty-2', 'fresh', 'driving')).toBe(true) rejectYield?.(failure) await queue.waitForDrain() @@ -697,8 +701,8 @@ describe('pty input write queue', () => { }) const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) - queue.enqueue('pty-1', large) - queue.enqueue('pty-1', 'tail') + queue.enqueue('pty-1', large, 'driving') + queue.enqueue('pty-1', 'tail', 'driving') // First chunk is written synchronously, then the drain parks on the yield. expect(writes.length).toBe(1) @@ -716,9 +720,9 @@ describe('pty input write queue', () => { const stale = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) try { - expect(queue.enqueue('pty-1', stale)).toBe(true) + expect(queue.enqueue('pty-1', stale, 'driving')).toBe(true) queue.clear() - expect(queue.enqueue('pty-1', 'fresh')).toBe(true) + expect(queue.enqueue('pty-1', 'fresh', 'driving')).toBe(true) await vi.runAllTimersAsync() await queue.waitForDrain() @@ -755,7 +759,7 @@ describe('pty input write queue', () => { globalThis.gc?.() const heapBefore = process.memoryUsage().heapUsed for (let index = 0; index < 20_000; index += 1) { - await queue.enqueueAccepted('pty-1', 'x') + await queue.enqueueAccepted('pty-1', 'x', 'driving') } await queue.waitForDrain() globalThis.gc?.() diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts index 31b37ed6af5..7f646354b27 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -12,6 +12,7 @@ import { type PtyInputWriteQueue, type PtyInputWriteQueueDeps } from './pty-input-write-queue-contract' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import { createHeadQueue, peekHeadQueue, @@ -111,7 +112,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput // Why: one cancel per in-flight write rather than `.then()` on a queue-lifetime // promise — those reactions are retained until that promise settles, so a // long-lived pane accumulated one record per acknowledged write (Esc, Ctrl+C). - async function writeAcceptedChunk(id: string, data: string): Promise { + async function writeAcceptedChunk(item: PendingPtyInputWrite, data: string): Promise { let cancel = (): void => undefined const cancelled = new Promise((resolve) => { cancel = () => resolve(false) @@ -122,7 +123,9 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput try { return await Promise.race([ cancelled, - Promise.resolve(deps.writeAccepted?.(id, data) ?? false).catch(() => false) + Promise.resolve(deps.writeAccepted?.(item.id, data, item.inputKind) ?? false).catch( + () => false + ) ]) } finally { pendingAcceptedCancels.delete(cancel) @@ -173,6 +176,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput peek.tooLarge !== false || peek.chunks !== undefined || !isCoalesciblePtyInput(peek) || + peek.inputKind !== next.inputKind || payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS ) { break @@ -180,7 +184,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput payload += peek.text removePending(peek) } - deps.write(next.id, payload) + deps.write(next.id, payload, next.inputKind) if (firstPending()) { await yieldBetweenWrites() } @@ -196,8 +200,8 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } const writeGeneration = generation const accepted = next.resolveAccepted - ? await writeAcceptedChunk(next.id, chunk.value) - : (deps.write(next.id, chunk.value), true) + ? await writeAcceptedChunk(next, chunk.value) + : (deps.write(next.id, chunk.value, next.inputKind), true) if (generation !== writeGeneration || firstPending() !== next) { continue } @@ -249,8 +253,9 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput function enqueueInput( id: string, - data: string, + text: string, queryReply: boolean, + inputKind: TerminalInputKind, resolveAccepted?: PendingPtyInputWrite['resolveAccepted'] ): boolean { try { @@ -260,20 +265,28 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } // Every query reply stays atomic so host-side ordering can classify it (#13892). const replyOnly = queryReply - if (replyOnly && !admitReply(data)) { + if (replyOnly && !admitReply(text)) { return false } - const tooLarge = replyOnly ? false : isTerminalInputTooLargeWithDeferredMeasurement(data) + const tooLarge = replyOnly ? false : isTerminalInputTooLargeWithDeferredMeasurement(text) if (tooLarge === true) { resolveAccepted?.(false) return false } - const item = { sequence: nextSequence, id, text: data, replyOnly, tooLarge, resolveAccepted } + const item = { + sequence: nextSequence, + id, + text, + replyOnly, + inputKind, + tooLarge, + resolveAccepted + } nextSequence += 1 if (replyOnly) { pendingReplies.items.push(item) pendingReplyCount += 1 - pendingReplyCodeUnits += data.length + pendingReplyCodeUnits += text.length } else { pendingOrdinary.items.push(item) } @@ -286,17 +299,17 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } return { - enqueue(id: string, data: string): boolean { - return enqueueInput(id, data, false) + enqueue(id: string, data: string, inputKind: TerminalInputKind): boolean { + return enqueueInput(id, data, false, inputKind) }, enqueueQueryReply(id: string, data: string): boolean { - return enqueueInput(id, data, true) + return enqueueInput(id, data, true, 'query-reply') }, - enqueueAccepted: (id, data) => + enqueueAccepted: (id, data, inputKind) => new Promise((resolve) => { - enqueueInput(id, data, false, resolve) + enqueueInput(id, data, false, inputKind, resolve) }), async waitForDrain(): Promise { diff --git a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts index d36c3a274ed..2595863cb3d 100644 --- a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts @@ -11,9 +11,9 @@ describe('createPtyPreconnectInputBuffer', () => { const acceptedWrite = createDeferred() const buffer = createPtyPreconnectInputBuffer() const delivered: string[] = [] - const accepted = buffer.enqueueAccepted('first') - expect(buffer.enqueue('second', 'ordinary')).toBe(true) - expect(buffer.enqueue('third', 'immediate')).toBe(true) + const accepted = buffer.enqueueAccepted('first', 'driving') + expect(buffer.enqueue('second', 'ordinary', 'driving')).toBe(true) + expect(buffer.enqueue('third', 'immediate', 'driving')).toBe(true) const writer = { isCurrent: () => true, sendInput: (data: string) => { @@ -52,9 +52,9 @@ describe('createPtyPreconnectInputBuffer', () => { const writeStarted = createDeferred() const buffer = createPtyPreconnectInputBuffer() const sendInput = vi.fn(() => true) - const accepted = buffer.enqueueAccepted('first') - const laterAccepted = buffer.enqueueAccepted('third') - expect(buffer.enqueue('second', 'ordinary')).toBe(true) + const accepted = buffer.enqueueAccepted('first', 'driving') + const laterAccepted = buffer.enqueueAccepted('third', 'driving') + expect(buffer.enqueue('second', 'ordinary', 'driving')).toBe(true) const flushing = buffer.flush({ isCurrent: () => true, sendInput, @@ -72,7 +72,7 @@ describe('createPtyPreconnectInputBuffer', () => { await expect(laterAccepted).resolves.toBe(false) await expect(flushing).resolves.toBeUndefined() expect(sendInput).not.toHaveBeenCalled() - expect(buffer.enqueue('after-clear', 'ordinary')).toBe(false) + expect(buffer.enqueue('after-clear', 'ordinary', 'driving')).toBe(false) if (lateOutcome === 'resolve') { acceptedWrite.resolve(true) @@ -91,7 +91,8 @@ describe('createPtyPreconnectInputBuffer', () => { const codeUnitWriteStarted = createDeferred() const codeUnitBuffer = createPtyPreconnectInputBuffer() const codeUnitAccepted = codeUnitBuffer.enqueueAccepted( - 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS) + 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), + 'driving' ) const codeUnitFlush = codeUnitBuffer.flush({ isCurrent: () => true, @@ -104,7 +105,7 @@ describe('createPtyPreconnectInputBuffer', () => { }) await codeUnitWriteStarted.promise - expect(codeUnitBuffer.enqueue('overflow', 'ordinary')).toBe(false) + expect(codeUnitBuffer.enqueue('overflow', 'ordinary', 'driving')).toBe(false) codeUnitBuffer.clear() codeUnitWrite.resolve(true) @@ -114,9 +115,9 @@ describe('createPtyPreconnectInputBuffer', () => { const entryWrite = createDeferred() const entryWriteStarted = createDeferred() const entryBuffer = createPtyPreconnectInputBuffer() - const entryAccepted = entryBuffer.enqueueAccepted('first') + const entryAccepted = entryBuffer.enqueueAccepted('first', 'driving') for (let index = 1; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) { - expect(entryBuffer.enqueue('', 'ordinary')).toBe(true) + expect(entryBuffer.enqueue('', 'ordinary', 'driving')).toBe(true) } const entryFlush = entryBuffer.flush({ isCurrent: () => true, @@ -129,7 +130,7 @@ describe('createPtyPreconnectInputBuffer', () => { }) await entryWriteStarted.promise - expect(entryBuffer.enqueue('', 'ordinary')).toBe(false) + expect(entryBuffer.enqueue('', 'ordinary', 'driving')).toBe(false) entryBuffer.clear() entryWrite.resolve(true) @@ -141,12 +142,13 @@ describe('createPtyPreconnectInputBuffer', () => { const entryBuffer = createPtyPreconnectInputBuffer( Array.from({ length: PTY_PRECONNECT_INPUT_MAX_ENTRIES + 1 }, () => ({ data: '', - kind: 'ordinary' as const + kind: 'ordinary' as const, + inputKind: 'driving' as const })) ) let entryWrites = 0 - expect(entryBuffer.enqueue('', 'ordinary')).toBe(false) + expect(entryBuffer.enqueue('', 'ordinary', 'driving')).toBe(false) await entryBuffer.flush({ isCurrent: () => true, sendInput: () => { @@ -158,12 +160,16 @@ describe('createPtyPreconnectInputBuffer', () => { expect(entryWrites).toBe(PTY_PRECONNECT_INPUT_MAX_ENTRIES) const codeUnitBuffer = createPtyPreconnectInputBuffer([ - { data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), kind: 'ordinary' }, - { data: 'overflow', kind: 'ordinary' } + { + data: 'x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS), + kind: 'ordinary', + inputKind: 'driving' + }, + { data: 'overflow', kind: 'ordinary', inputKind: 'driving' } ]) const codeUnitWrites: number[] = [] - expect(codeUnitBuffer.enqueue('new', 'ordinary')).toBe(false) + expect(codeUnitBuffer.enqueue('new', 'ordinary', 'driving')).toBe(false) await codeUnitBuffer.flush({ isCurrent: () => true, sendInput: (data) => { @@ -179,7 +185,7 @@ describe('createPtyPreconnectInputBuffer', () => { // Regression: an abandoned deferred spawn never reaches connect(), so nothing // drained the buffer and a paste awaiting sendInputAccepted hung forever. const buffer = createPtyPreconnectInputBuffer() - const pastePending = buffer.enqueueAccepted('pasted text') + const pastePending = buffer.enqueueAccepted('pasted text', 'driving') let settled = false void pastePending.then(() => { settled = true diff --git a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts index 3dce1962bb9..cb8dec2d6d0 100644 --- a/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.ts @@ -1,4 +1,5 @@ import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' export const PTY_PRECONNECT_INPUT_MAX_ENTRIES = 1024 // Why: a retention budget, not the 16MB single-write ceiling. The deferral lasts @@ -12,6 +13,7 @@ export type PtyPreconnectInputKind = 'ordinary' | 'immediate' | 'accepted' export type PtyPreconnectInputEntry = { data: string kind: PtyPreconnectInputKind + inputKind: TerminalInputKind } type BufferedInput = PtyPreconnectInputEntry & { @@ -20,9 +22,9 @@ type BufferedInput = PtyPreconnectInputEntry & { type PreconnectInputWriter = { isCurrent: () => boolean - sendInput: (data: string) => boolean + sendInput: (data: string, inputKind: TerminalInputKind) => boolean sendInputImmediate: (data: string) => boolean - sendInputAccepted?: (data: string) => Promise + sendInputAccepted?: (data: string, inputKind: TerminalInputKind) => Promise } export type PtyPreconnectInputBuffer = { @@ -30,10 +32,12 @@ export type PtyPreconnectInputBuffer = { enqueue: ( data: string, kind: 'ordinary' | 'immediate', + inputKind: TerminalInputKind, onRetained?: (entry: PtyPreconnectInputEntry) => void ) => boolean enqueueAccepted: ( data: string, + inputKind: TerminalInputKind, onRetained?: (entry: PtyPreconnectInputEntry) => void ) => Promise flush: (writer: PreconnectInputWriter) => Promise @@ -70,14 +74,15 @@ export function createPtyPreconnectInputBuffer( const createInput = ( data: string, kind: PtyPreconnectInputKind, + inputKind: TerminalInputKind, resolve?: BufferedInput['resolve'] - ): BufferedInput => ({ data, kind, ...(resolve ? { resolve } : {}) }) + ): BufferedInput => ({ data, kind, inputKind, ...(resolve ? { resolve } : {}) }) const notifyRetained = ( onRetained: ((entry: PtyPreconnectInputEntry) => void) | undefined, input: BufferedInput ): void => { try { - onRetained?.({ data: input.data, kind: input.kind }) + onRetained?.({ data: input.data, kind: input.kind, inputKind: input.inputKind }) } catch { // Handoff capture is advisory; a callback failure must not reject input admission. } @@ -86,7 +91,7 @@ export function createPtyPreconnectInputBuffer( // Seeded entries came from a predecessor transport and must not be reported // back to that predecessor's handoff owner as newly typed input. for (const entry of initialEntries) { - retain(createInput(entry.data, entry.kind)) + retain(createInput(entry.data, entry.kind, entry.inputKind)) } const clear = (): void => { const dropped = pending @@ -124,8 +129,8 @@ export function createPtyPreconnectInputBuffer( accepted = await Promise.race([ Promise.resolve( writer.sendInputAccepted - ? writer.sendInputAccepted(input.data) - : writer.sendInput(input.data) + ? writer.sendInputAccepted(input.data, input.inputKind) + : writer.sendInput(input.data, input.inputKind) ), flushStopped.then(() => null) ]) @@ -152,7 +157,7 @@ export function createPtyPreconnectInputBuffer( const accepted = input.kind === 'immediate' ? writer.sendInputImmediate(input.data) - : writer.sendInput(input.data) + : writer.sendInput(input.data, input.inputKind) if (!accepted) { clear() return @@ -186,17 +191,17 @@ export function createPtyPreconnectInputBuffer( return { isBuffering: () => buffering, - enqueue(data, kind, onRetained) { - const input = createInput(data, kind) + enqueue(data, kind, inputKind, onRetained) { + const input = createInput(data, kind, inputKind) const retained = retain(input) if (retained) { notifyRetained(onRetained, input) } return retained }, - enqueueAccepted(data, onRetained) { + enqueueAccepted(data, inputKind, onRetained) { return new Promise((resolve) => { - const input = createInput(data, 'accepted', resolve) + const input = createInput(data, 'accepted', inputKind, resolve) if (!retain(input)) { resolve(false) return diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index 129c920f976..ed9c029d14c 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -68,6 +68,22 @@ describe('createIpcPtyTransport', () => { expect(spawn).not.toHaveBeenCalled() }) + it('keeps the recovery hint and raw diagnostic from a wrapped spawn error', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( + new Error( + "Error invoking remote method 'pty:spawn': Error: Close unused terminals, then try again.\nnode-pty: open_slave failed: EMFILE (errno 24)" + ) + ) + const onError = vi.fn() + + await createIpcPtyTransport({}).connect({ url: '', callbacks: { onError } }) + + expect(onError).toHaveBeenCalledWith( + 'Close unused terminals, then try again.\nnode-pty: open_slave failed: EMFILE (errno 24)' + ) + }) + it('threads provider command ownership through the spawn IPC', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ diff --git a/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts index 1060d617dd2..df2c11e08d4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts @@ -65,8 +65,8 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({}) await transport.connect({ url: '', callbacks: { onWriteUnavailable: recovery } }) - expect(transport.sendInput('input')).toBe(true) - expect(transport.sendInput('later-input')).toBe(false) + expect(transport.sendInput('input', 'driving')).toBe(true) + expect(transport.sendInput('later-input', 'driving')).toBe(false) expect(recovery).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith('[pty-input-write-queue] drain failed:', failure) @@ -80,8 +80,8 @@ describe('createIpcPtyTransport', () => { const localTransport = createIpcPtyTransport({}) await localTransport.connect({ url: '', callbacks: {} }) - await expect(localTransport.sendInputAccepted?.('\x03')).resolves.toBe(true) - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', '\x03') + await expect(localTransport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(true) + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', '\x03', 'driving') const sshTransport = createIpcPtyTransport({ connectionId: 'ssh-1' }) await sshTransport.connect({ url: '', callbacks: {} }) @@ -103,8 +103,8 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('first-')).toBe(true) - const accepted = transport.sendInputAccepted?.('second-') + expect(transport.sendInput('first-', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second-', 'driving') expect(transport.sendInputImmediate('third')).toBe(true) expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() @@ -133,21 +133,21 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true, preconnectInput: [ - { data: 'before-remount-', kind: 'ordinary' }, - { data: '\x1b[0n', kind: 'immediate' }, - { data: '\x03', kind: 'accepted' } + { data: 'before-remount-', kind: 'ordinary', inputKind: 'driving' }, + { data: '\x1b[0n', kind: 'immediate', inputKind: 'query-reply' }, + { data: '\x03', kind: 'accepted', inputKind: 'driving' } ], onPreconnectInput }) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('new-ordinary-')).toBe(true) + expect(transport.sendInput('new-ordinary-', 'driving')).toBe(true) expect(transport.sendInputImmediate('new-immediate-')).toBe(true) - const accepted = transport.sendInputAccepted?.('new-accepted') + const accepted = transport.sendInputAccepted?.('new-accepted', 'driving') expect(onPreconnectInput.mock.calls).toEqual([ - [{ data: 'new-ordinary-', kind: 'ordinary' }], - [{ data: 'new-immediate-', kind: 'immediate' }], - [{ data: 'new-accepted', kind: 'accepted' }] + [{ data: 'new-ordinary-', kind: 'ordinary', inputKind: 'driving' }], + [{ data: 'new-immediate-', kind: 'immediate', inputKind: 'query-reply' }], + [{ data: 'new-accepted', kind: 'accepted', inputKind: 'driving' }] ]) spawn.resolve({ id: 'pty-1' }) @@ -180,8 +180,8 @@ describe('createIpcPtyTransport', () => { onPreconnectInput: (input) => captured.push(input) }) - const predecessorAccepted = predecessor.sendInputAccepted?.('\x03') - expect(captured).toEqual([{ data: '\x03', kind: 'accepted' }]) + const predecessorAccepted = predecessor.sendInputAccepted?.('\x03', 'driving') + expect(captured).toEqual([{ data: '\x03', kind: 'accepted', inputKind: 'driving' }]) const successor = createIpcPtyTransport({ preconnectInput: captured }) const connecting = successor.connect({ url: '', callbacks: {} }) @@ -205,17 +205,17 @@ describe('createIpcPtyTransport', () => { onPreconnectInput }) - expect(transport.sendInput('ordinary')).toBe(true) + expect(transport.sendInput('ordinary', 'driving')).toBe(true) expect(transport.sendInputImmediate('immediate')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + const accepted = transport.sendInputAccepted?.('accepted', 'driving') expect(onPreconnectInput).toHaveBeenCalledTimes(3) await transport.connect({ url: '', callbacks: {} }) await expect(accepted).resolves.toBe(true) - expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'ordinary') - expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'immediate') - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'accepted') + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'ordinary', 'driving') + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'immediate', 'query-reply') + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'accepted', 'driving') }) it('keeps live acknowledged input ahead of later ordinary and immediate writes', async () => { @@ -235,9 +235,9 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({}) await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('first')).toBe(true) - const accepted = transport.sendInputAccepted?.('second') - expect(transport.sendInput('third')).toBe(true) + expect(transport.sendInput('first', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second', 'driving') + expect(transport.sendInput('third', 'driving')).toBe(true) expect(transport.sendInputImmediate('fourth')).toBe(true) await flushAsyncTicks() @@ -275,11 +275,11 @@ describe('createIpcPtyTransport', () => { const slowInput = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(slowInput)).toBe(true) + expect(transport.sendInput(slowInput, 'driving')).toBe(true) await connecting - const accepted = transport.sendInputAccepted?.('accepted') - expect(transport.sendInput('later')).toBe(true) + const accepted = transport.sendInputAccepted?.('accepted', 'driving') + expect(transport.sendInput('later', 'driving')).toBe(true) expect(transport.sendInputImmediate('reply')).toBe(true) expect(delivered).toEqual([]) @@ -318,14 +318,14 @@ describe('createIpcPtyTransport', () => { const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) - expect(transport.sendInput('ordinary')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + expect(transport.sendInput('ordinary', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('accepted', 'driving') await transport.destroy?.() await expect(accepted).resolves.toBe(false) - expect(transport.sendInput('after-destroy')).toBe(false) + expect(transport.sendInput('after-destroy', 'driving')).toBe(false) expect(transport.sendInputImmediate('after-destroy')).toBe(false) - await expect(transport.sendInputAccepted?.('after-destroy')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('after-destroy', 'driving')).resolves.toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() }) @@ -341,11 +341,11 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const connecting = transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.('first') - expect(transport.sendInput('later')).toBe(true) + const accepted = transport.sendInputAccepted?.('first', 'driving') + expect(transport.sendInput('later', 'driving')).toBe(true) spawn.resolve({ id: 'pty-1' }) await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first') + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first', 'driving') if (teardown === 'disconnect') { transport.disconnect() @@ -358,7 +358,7 @@ describe('createIpcPtyTransport', () => { await expect(accepted).resolves.toBe(false) await expect(connecting).resolves.toBe('pty-1') expect(window.api.pty.write).not.toHaveBeenCalled() - expect(transport.sendInput('after-teardown')).toBe(false) + expect(transport.sendInput('after-teardown', 'driving')).toBe(false) acceptedWrite.resolve(true) await flushAsyncTicks() @@ -377,10 +377,10 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({}) await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.('first') - expect(transport.sendInput('later')).toBe(true) + const accepted = transport.sendInputAccepted?.('first', 'driving') + expect(transport.sendInput('later', 'driving')).toBe(true) await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first') + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'first', 'driving') if (teardown === 'disconnect') { transport.disconnect() @@ -392,7 +392,7 @@ describe('createIpcPtyTransport', () => { await expect(accepted).resolves.toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() - expect(transport.sendInput('after-teardown')).toBe(false) + expect(transport.sendInput('after-teardown', 'driving')).toBe(false) acceptedWrite.resolve(true) await flushAsyncTicks() @@ -412,7 +412,7 @@ describe('createIpcPtyTransport', () => { const onConnect = vi.fn() const connecting = transport.connect({ url: '', callbacks: { onConnect } }) - const accepted = transport.sendInputAccepted?.('pending') + const accepted = transport.sendInputAccepted?.('pending', 'driving') if (teardown === 'disconnect') { transport.disconnect() } else { @@ -551,18 +551,18 @@ describe('createIpcPtyTransport', () => { const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(`${chunk}${chunk}`)).toBe(true) - expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', chunk) + expect(transport.sendInput(`${chunk}${chunk}`, 'driving')).toBe(true) + expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', chunk, 'driving') onExit?.({ id: 'pty-1', code: 0 }) transport.attach({ existingPtyId: 'pty-1', callbacks: {} }) - expect(transport.sendInput('fresh')).toBe(true) + expect(transport.sendInput('fresh', 'driving')).toBe(true) await vi.runAllTimersAsync() expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ - ['pty-1', chunk], - ['pty-1', 'fresh'] + ['pty-1', chunk, 'driving'], + ['pty-1', 'fresh', 'driving'] ]) } finally { vi.useRealTimers() @@ -577,22 +577,22 @@ describe('createIpcPtyTransport', () => { const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.(`${chunk}stale-tail`) + const accepted = transport.sendInputAccepted?.(`${chunk}stale-tail`, 'driving') await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk) + expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk, 'driving') onExit?.({ id: 'pty-1', code: 0 }) transport.attach({ existingPtyId: 'pty-1', callbacks: {} }) - expect(transport.sendInput('fresh')).toBe(true) + expect(transport.sendInput('fresh', 'driving')).toBe(true) await expect(accepted).resolves.toBe(false) await flushAsyncTicks() - expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', 'fresh') + expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith('pty-1', 'fresh', 'driving') acceptedWrite.resolve(true) await flushAsyncTicks() - expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk) + expect(window.api.pty.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', chunk, 'driving') await expect(accepted).resolves.toBe(false) }) @@ -601,13 +601,13 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) expect(transport.sendInputImmediate('immediate')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + const accepted = transport.sendInputAccepted?.('accepted', 'driving') transport.disconnect() await expect(accepted).resolves.toBe(false) - expect(transport.sendInput('after-disconnect')).toBe(false) + expect(transport.sendInput('after-disconnect', 'driving')).toBe(false) expect(transport.sendInputImmediate('after-disconnect')).toBe(false) - await expect(transport.sendInputAccepted?.('after-disconnect')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('after-disconnect', 'driving')).resolves.toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() }) @@ -619,13 +619,13 @@ describe('createIpcPtyTransport', () => { bufferPreHandlerPtyExit(ptyId, 0) const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) - const accepted = transport.sendInputAccepted?.('input') + const accepted = transport.sendInputAccepted?.('input', 'driving') transport.attach({ existingPtyId: ptyId, callbacks: {} }) await expect(accepted).resolves.toBe(false) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('after-exit')).toBe(false) + expect(transport.sendInput('after-exit', 'driving')).toBe(false) clearPreHandlerPtyState(ptyId) }) @@ -635,7 +635,7 @@ describe('createIpcPtyTransport', () => { }) const { createIpcPtyTransport } = await import('./pty-transport') const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) - const accepted = transport.sendInputAccepted?.('input') + const accepted = transport.sendInputAccepted?.('input', 'driving') expect(() => transport.attach({ existingPtyId: 'pty-attach-failure', callbacks: {} })).toThrow( 'dispatcher attach failed' @@ -643,7 +643,7 @@ describe('createIpcPtyTransport', () => { await expect(accepted).resolves.toBe(false) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('after-failure')).toBe(false) + expect(transport.sendInput('after-failure', 'driving')).toBe(false) }) it('settles and drops preconnect input when the split spawn fails', async () => { @@ -652,14 +652,14 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const onError = vi.fn() - expect(transport.sendInput('ordinary')).toBe(true) - const accepted = transport.sendInputAccepted?.('accepted') + expect(transport.sendInput('ordinary', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('accepted', 'driving') await transport.connect({ url: '', callbacks: { onError } }) await expect(accepted).resolves.toBe(false) - expect(transport.sendInput('after-failure')).toBe(false) + expect(transport.sendInput('after-failure', 'driving')).toBe(false) expect(transport.sendInputImmediate('after-failure')).toBe(false) - await expect(transport.sendInputAccepted?.('after-failure')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('after-failure', 'driving')).resolves.toBe(false) expect(onError).toHaveBeenCalledWith('spawn failed') expect(window.api.pty.write).not.toHaveBeenCalled() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() @@ -673,16 +673,16 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) const connecting = transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('first')).toBe(true) - const accepted = transport.sendInputAccepted?.('second') - expect(transport.sendInput('third')).toBe(true) + expect(transport.sendInput('first', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second', 'driving') + expect(transport.sendInput('third', 'driving')).toBe(true) spawn.resolve({ id: 'pty-1' }) await connecting await expect(accepted).resolves.toBe(false) expect(window.api.pty.write).toHaveBeenCalledOnce() - expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'first') + expect(window.api.pty.write).toHaveBeenCalledWith('pty-1', 'first', 'driving') }) it('drops acknowledged preconnect input when an earlier ordinary write fails', async () => { @@ -695,8 +695,8 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) try { - expect(transport.sendInput('first')).toBe(true) - const accepted = transport.sendInputAccepted?.('second') + expect(transport.sendInput('first', 'driving')).toBe(true) + const accepted = transport.sendInputAccepted?.('second', 'driving') await transport.connect({ url: '', callbacks: {} }) await expect(accepted).resolves.toBe(false) @@ -712,12 +712,14 @@ describe('createIpcPtyTransport', () => { const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) for (let index = 0; index < PTY_PRECONNECT_INPUT_MAX_ENTRIES; index += 1) { - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) } - expect(transport.sendInput('overflow')).toBe(false) + expect(transport.sendInput('overflow', 'driving')).toBe(false) const oversized = createIpcPtyTransport({ bufferInputUntilConnect: true }) - expect(oversized.sendInput('x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS + 1))).toBe(false) + expect( + oversized.sendInput('x'.repeat(PTY_PRECONNECT_INPUT_MAX_CODE_UNITS + 1), 'driving') + ).toBe(false) await transport.destroy?.() await oversized.destroy?.() }) @@ -731,14 +733,14 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(`${chunk}tail`)).toBe(true) + expect(transport.sendInput(`${chunk}tail`, 'driving')).toBe(true) expect(window.api.pty.write).toHaveBeenCalledTimes(1) - expect(window.api.pty.write).toHaveBeenNthCalledWith(1, 'pty-1', chunk) + expect(window.api.pty.write).toHaveBeenNthCalledWith(1, 'pty-1', chunk, 'driving') await vi.runOnlyPendingTimersAsync() expect(window.api.pty.write).toHaveBeenCalledTimes(2) - expect(window.api.pty.write).toHaveBeenNthCalledWith(2, 'pty-1', 'tail') + expect(window.api.pty.write).toHaveBeenNthCalledWith(2, 'pty-1', 'tail', 'driving') } finally { vi.useRealTimers() } @@ -755,7 +757,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) expect(transport.sendInputImmediate(first)).toBe(true) - expect(transport.sendInput(ordinary)).toBe(true) + expect(transport.sendInput(ordinary, 'driving')).toBe(true) for (const reply of replies) { expect(transport.sendInputImmediate(reply)).toBe(true) } @@ -763,11 +765,11 @@ describe('createIpcPtyTransport', () => { await vi.runAllTimersAsync() expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ - ['pty-1', first], - ['pty-1', ordinary], + ['pty-1', first, 'query-reply'], + ['pty-1', ordinary, 'driving'], ...replies .slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES) - .map((reply) => ['pty-1', reply]) + .map((reply) => ['pty-1', reply, 'query-reply']) ]) } finally { vi.useRealTimers() @@ -783,7 +785,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput(text)).toBe(true) + expect(transport.sendInput(text, 'driving')).toBe(true) expect(window.api.pty.write).not.toHaveBeenCalled() await vi.runAllTimersAsync() @@ -805,7 +807,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - expect(transport.sendInput('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + expect(transport.sendInput('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1), 'driving')).toBe(false) expect(window.api.pty.write).not.toHaveBeenCalled() }) @@ -818,16 +820,16 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.(`${chunk}tail`) + const accepted = transport.sendInputAccepted?.(`${chunk}tail`, 'driving') await Promise.resolve() expect(window.api.pty.writeAccepted).toHaveBeenCalledTimes(1) - expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(1, 'pty-1', chunk) + expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(1, 'pty-1', chunk, 'driving') await vi.runOnlyPendingTimersAsync() await expect(accepted).resolves.toBe(true) expect(window.api.pty.writeAccepted).toHaveBeenCalledTimes(2) - expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(2, 'pty-1', 'tail') + expect(window.api.pty.writeAccepted).toHaveBeenNthCalledWith(2, 'pty-1', 'tail', 'driving') } finally { vi.useRealTimers() } @@ -842,7 +844,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - const accepted = transport.sendInputAccepted?.(text) + const accepted = transport.sendInputAccepted?.(text, 'driving') await Promise.resolve() expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() @@ -867,7 +869,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) await expect( - transport.sendInputAccepted?.('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1)) + transport.sendInputAccepted?.('x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1), 'driving') ).resolves.toBe(false) expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts index 7eeba23ec8d..b10fd3fe1ba 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts @@ -92,7 +92,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: { onError } }) expect(onError).toHaveBeenCalled() expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('echo hello\r')).toBe(false) + expect(transport.sendInput('echo hello\r', 'driving')).toBe(false) await flushPtySideEffects() expect(write).not.toHaveBeenCalled() @@ -102,7 +102,7 @@ describe('createIpcPtyTransport', () => { await transport.connect({ url: '', callbacks: { onError: onErrorKilled } }) expect(onErrorKilled).not.toHaveBeenCalled() expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('echo hello\r')).toBe(false) + expect(transport.sendInput('echo hello\r', 'driving')).toBe(false) await flushPtySideEffects() expect(write).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 3b835b9d0a2..7fd0de6952e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -10,6 +10,7 @@ import type { import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { EventProps } from '../../../../shared/telemetry-events' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply' import type { TuiAgent } from '../../../../shared/tui-agent' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -182,7 +183,7 @@ export type PtyTransport = { callbacks: PtyCallbacks }) => void disconnect: () => void - sendInput: (data: string) => boolean + sendInput: (data: string, inputKind: TerminalInputKind) => boolean // Why: latency-critical terminal query replies (CPR/DSR/DA/OSC color/pixel // size) must skip input coalescing — a querying program reads them in raw // mode with a short timeout, so a debounced reply lands on the shell prompt @@ -190,7 +191,7 @@ export type PtyTransport = { // this is `sendInput` for them; the remote transport flushes pending input // (preserving order) and sends the reply immediately. sendInputImmediate: (data: string) => boolean - sendInputAccepted?: (data: string) => Promise + sendInputAccepted?: (data: string, inputKind: TerminalInputKind) => Promise /** Settles retained pre-connect input when a deferred spawn is abandoned before connect. */ abandonPreconnectInput?: () => void claimViewport?: (cols: number, rows: number) => boolean diff --git a/src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts new file mode 100644 index 00000000000..34a71b81b8c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-transport-user-input.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDeferred, flushAsyncTicks } from './pty-connection-test-async' +import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' +import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' + +describe('createIpcPtyTransport: input kind', () => { + const originalWindow: typeof window | undefined = globalThis.window + + beforeEach(() => { + vi.resetModules() + installIpcPtyWindow(originalWindow, {}) + }) + + afterEach(() => { + restorePtySpecWindow(originalWindow) + }) + + it('sends every write with the kind its writer gave it', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + + transport.sendInput('typed', 'driving') + await flushAsyncTicks() + transport.sendInput('\x1b[I', 'query-reply') + await flushAsyncTicks() + transport.sendInputImmediate('\x1b[1;1R') + await flushAsyncTicks() + await transport.sendInputAccepted?.('echo startup\r', 'launch') + + expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ + ['pty-1', 'typed', 'driving'], + ['pty-1', '\x1b[I', 'query-reply'], + ['pty-1', '\x1b[1;1R', 'query-reply'] + ]) + expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', 'echo startup\r', 'launch') + transport.disconnect() + }) + + it('never merges bytes of different kinds', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + + // The first write drains at once; what queues behind it would otherwise coalesce. + transport.sendInput('\x1b[O', 'query-reply') + transport.sendInput('\x1b[I', 'query-reply') + transport.sendInput('a', 'driving') + transport.sendInput('b', 'driving') + + await vi.waitFor(() => + expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ + ['pty-1', '\x1b[O', 'query-reply'], + ['pty-1', '\x1b[I', 'query-reply'], + ['pty-1', 'ab', 'driving'] + ]) + ) + transport.disconnect() + }) + + it('keeps the kind of input written before the spawn connects and across a remount', async () => { + const spawn = createDeferred>>() + vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise) + const { createIpcPtyTransport } = await import('./pty-transport') + const captured: PtyPreconnectInputEntry[] = [] + const predecessor = createIpcPtyTransport({ + bufferInputUntilConnect: true, + onPreconnectInput: (entry) => captured.push(entry) + }) + void predecessor.connect({ url: '', callbacks: {} }) + predecessor.sendInput('typed-early', 'driving') + predecessor.sendInput('startup', 'launch') + predecessor.destroy?.() + + const successor = createIpcPtyTransport({ preconnectInput: captured }) + spawn.resolve({ id: 'pty-1' }) + await successor.connect({ url: '', callbacks: {} }) + await flushAsyncTicks() + + expect(captured).toEqual([ + { data: 'typed-early', kind: 'ordinary', inputKind: 'driving' }, + { data: 'startup', kind: 'ordinary', inputKind: 'launch' } + ]) + expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ + ['pty-1', 'typed-early', 'driving'], + ['pty-1', 'startup', 'launch'] + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index a8adfccb7ac..fd26a7324b5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -5,6 +5,7 @@ import { createPtyInputWriteQueue } from './pty-input-write-queue' import { createPtyOutputProcessor } from './pty-output-processor' import { createPtyPreconnectInputBuffer } from './pty-preconnect-input-buffer' import type { IpcPtyTransportOptions, PtyTransport } from './pty-transport-types' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' export { ensurePtyDispatcher, @@ -57,8 +58,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra const inputWriteQueue = createPtyInputWriteQueue({ isWritable: (id) => !destroyed && connected && ptyId === id, - write: (id, data) => window.api.pty.write(id, data), - writeAccepted: (id, data) => window.api.pty.writeAccepted(id, data), + write: (id, data, inputKind) => window.api.pty.write(id, data, inputKind), + writeAccepted: (id, data, inputKind) => window.api.pty.writeAccepted(id, data, inputKind), onDrainFailure: (id) => { if (ptyId === id) { storedCallbacks.onWriteUnavailable?.() @@ -115,12 +116,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } await preconnectInputBuffer.flush({ isCurrent: () => !destroyed && connected && ptyId === id, - sendInput: (data) => inputWriteQueue.enqueue(id, data), + sendInput: (data, inputKind) => inputWriteQueue.enqueue(id, data, inputKind), sendInputImmediate: (data) => inputWriteQueue.enqueueQueryReply(id, data), ...(connectionId ? {} : { - sendInputAccepted: (data: string) => inputWriteQueue.enqueueAccepted(id, data) + sendInputAccepted: (data: string, inputKind: TerminalInputKind) => + inputWriteQueue.enqueueAccepted(id, data, inputKind) }) }) } @@ -213,16 +215,23 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra storedCallbacks = {} }, - sendInput(data) { + sendInput(data, inputKind) { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueue(data, 'ordinary', opts.onPreconnectInput) + return preconnectInputBuffer.enqueue(data, 'ordinary', inputKind, opts.onPreconnectInput) } - return !destroyed && connected && ptyId ? inputWriteQueue.enqueue(ptyId, data) : false + return !destroyed && connected && ptyId + ? inputWriteQueue.enqueue(ptyId, data, inputKind) + : false }, sendInputImmediate(data) { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueue(data, 'immediate', opts.onPreconnectInput) + return preconnectInputBuffer.enqueue( + data, + 'immediate', + 'query-reply', + opts.onPreconnectInput + ) } return !destroyed && connected && ptyId ? inputWriteQueue.enqueueQueryReply(ptyId, data) @@ -232,14 +241,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ...(connectionId ? {} : { - async sendInputAccepted(data: string): Promise { + async sendInputAccepted(data: string, inputKind: TerminalInputKind): Promise { if (!destroyed && preconnectInputBuffer?.isBuffering()) { - return preconnectInputBuffer.enqueueAccepted(data, opts.onPreconnectInput) + return preconnectInputBuffer.enqueueAccepted(data, inputKind, opts.onPreconnectInput) } if (destroyed || !connected || !ptyId) { return false } - return inputWriteQueue.enqueueAccepted(ptyId, data) + return inputWriteQueue.enqueueAccepted(ptyId, data, inputKind) } }), diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts index 1df9a355a05..b07afbb20d3 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-error-surface-dismissal.test.ts @@ -48,18 +48,18 @@ describe('remote runtime error surface dismissal', () => { const onError = vi.fn() const transport = await attachTransportWithFatalSends(onError) - expect(await transport.sendInputAccepted?.('a')).toBe(false) - expect(await transport.sendInputAccepted?.('b')).toBe(false) + expect(await transport.sendInputAccepted?.('a', 'driving')).toBe(false) + expect(await transport.sendInputAccepted?.('b', 'driving')).toBe(false) // Contract preserved: one continuous outage still surfaces the repeated error once. expect(onError.mock.calls).toEqual([[FATAL_ERROR]]) transport.notifyErrorSurfaceDismissed?.() - expect(await transport.sendInputAccepted?.('c')).toBe(false) + expect(await transport.sendInputAccepted?.('c', 'driving')).toBe(false) expect(onError.mock.calls).toEqual([[FATAL_ERROR], [FATAL_ERROR]]) // The memory re-arms: repeats after the re-surfaced error are suppressed again until the next dismissal. - expect(await transport.sendInputAccepted?.('d')).toBe(false) + expect(await transport.sendInputAccepted?.('d', 'driving')).toBe(false) expect(onError).toHaveBeenCalledTimes(2) transport.destroy?.() }) @@ -68,9 +68,9 @@ describe('remote runtime error surface dismissal', () => { const onError = vi.fn() const transport = await attachTransportWithFatalSends(onError) - expect(await transport.sendInputAccepted?.('a')).toBe(false) - expect(await transport.sendInputAccepted?.('b')).toBe(false) - expect(await transport.sendInputAccepted?.('c')).toBe(false) + expect(await transport.sendInputAccepted?.('a', 'driving')).toBe(false) + expect(await transport.sendInputAccepted?.('b', 'driving')).toBe(false) + expect(await transport.sendInputAccepted?.('c', 'driving')).toBe(false) expect(onError).toHaveBeenCalledTimes(1) transport.destroy?.() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts index ec46891e9d5..6d8616a1baa 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts @@ -71,7 +71,7 @@ describe('remote transport sendInputImmediate (#7329)', () => { await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) // Typed input: debounced — nothing sent before the 8ms flush. - expect(transport.sendInput('yes')).toBe(true) + expect(transport.sendInput('yes', 'driving')).toBe(true) expect(terminalSendCalls()).toEqual([]) // Query reply (OSC 11 background color): sent immediately, no timer. @@ -139,10 +139,10 @@ describe('remote transport sendInputImmediate (#7329)', () => { // A paste above CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS forces the async // validation path, so its bytes are captured in validationTail, not pending. const paste = 'p'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - expect(transport.sendInput(paste)).toBe(true) + expect(transport.sendInput(paste, 'driving')).toBe(true) // Immediately (validation still pending) a TUI emits a CPR reply. expect(transport.sendInputImmediate('\x1b[3;1R')).toBe(true) - expect(transport.sendInput('z')).toBe(true) + expect(transport.sendInput('z', 'driving')).toBe(true) // Wait until the reply is actually flushed instead of sleeping a fixed // interval: the reply legitimately trails the paste's async validation plus diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index 6185198d59e..f5861cbc810 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -523,7 +523,7 @@ describe('createRemoteRuntimePtyTransport', () => { } send.mockClear() - expect(transport.sendInput('ls\r')).toBe(true) + expect(transport.sendInput('ls\r', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() const inputFrame = decodeTerminalStreamFrame(send.mock.calls[0][0]) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts index 398efce980a..58034193be8 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts @@ -249,7 +249,9 @@ describe('createRemoteRuntimePtyTransport', () => { expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) const listCallsAfterBound = hostListCalls - await expect(transport.sendInputAccepted?.('retry while reconnecting')).resolves.toBe(false) + await expect( + transport.sendInputAccepted?.('retry while reconnecting', 'driving') + ).resolves.toBe(false) await vi.advanceTimersByTimeAsync(16_000) // The accepted-snapshot listener already owns recovery. User input must diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts index 999716aa43d..b77f0b26e82 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts @@ -143,9 +143,9 @@ describe('createRemoteRuntimePtyTransport', () => { }) await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled()) expect(transport.claimViewport?.(101, 33)).toBe(true) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) expect(transport.sendInputImmediate('\x1b[?1;2c')).toBe(true) - expect(transport.sendInput('z')).toBe(true) + expect(transport.sendInput('z', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) expect(runtimeCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.send' }) @@ -193,8 +193,8 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() subscriptionSendBinary.mockClear() - expect(transport.sendInput('a')).toBe(true) - expect(transport.sendInput('b')).toBe(true) + expect(transport.sendInput('a', 'driving')).toBe(true) + expect(transport.sendInput('b', 'driving')).toBe(true) expect(runtimeCall).not.toHaveBeenCalled() await vi.runOnlyPendingTimersAsync() @@ -225,8 +225,8 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() subscriptionSendBinary.mockClear() - expect(transport.sendInput('a')).toBe(true) - expect(transport.sendInput('b')).toBe(true) + expect(transport.sendInput('a', 'driving')).toBe(true) + expect(transport.sendInput('b', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() @@ -256,14 +256,14 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionSendBinary.mockClear() const chunk = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) - expect(transport.sendInput(chunk)).toBe(true) + expect(transport.sendInput(chunk, 'driving')).toBe(true) expect(subscriptionSendBinary).toHaveBeenCalledTimes(1) let frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0]) expect(frame?.opcode).toBe(TerminalStreamOpcode.Input) expect(frame?.streamId).toBe(streamId) expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe(chunk) - expect(transport.sendInput('tail')).toBe(true) + expect(transport.sendInput('tail', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() @@ -299,7 +299,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true) + await expect(transport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(true) expect(runtimeCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'terminal.send', @@ -339,8 +339,8 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) subscriptionSendBinary.mockClear() - expect(transport.sendInput('a')).toBe(true) - await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true) + expect(transport.sendInput('a', 'driving')).toBe(true) + await expect(transport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).toHaveBeenCalledWith({ @@ -383,7 +383,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(false) + await expect(transport.sendInputAccepted?.('\x03', 'driving')).resolves.toBe(false) }) it('splits large acknowledged remote input before terminal.send RPCs', async () => { @@ -415,7 +415,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) const chunk = '😀'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES / 4) - await expect(transport.sendInputAccepted?.(`${chunk}tail`)).resolves.toBe(true) + await expect(transport.sendInputAccepted?.(`${chunk}tail`, 'driving')).resolves.toBe(true) const sendCalls = runtimeCall.mock.calls.filter((call) => call[0].method === 'terminal.send') expect(sendCalls).toHaveLength(2) @@ -455,7 +455,7 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) runtimeCall.mockClear() - const accepted = transport.sendInputAccepted?.(text) + const accepted = transport.sendInputAccepted?.(text, 'driving') await Promise.resolve() expect(runtimeCall).not.toHaveBeenCalled() @@ -502,9 +502,9 @@ describe('createRemoteRuntimePtyTransport', () => { await transport.connect({ url: '', callbacks: {} }) - await expect(transport.sendInputAccepted?.(`${firstChunk}${rejectedChunk}after`)).resolves.toBe( - false - ) + await expect( + transport.sendInputAccepted?.(`${firstChunk}${rejectedChunk}after`, 'driving') + ).resolves.toBe(false) const sendTexts = runtimeCall.mock.calls .filter((call) => call[0].method === 'terminal.send') @@ -530,7 +530,10 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() await expect( - transport.sendInputAccepted?.('😀'.repeat(Math.floor(TERMINAL_INPUT_MAX_BYTES / 4) + 1)) + transport.sendInputAccepted?.( + '😀'.repeat(Math.floor(TERMINAL_INPUT_MAX_BYTES / 4) + 1), + 'driving' + ) ).resolves.toBe(false) expect(runtimeCall).not.toHaveBeenCalled() }) @@ -550,7 +553,7 @@ describe('createRemoteRuntimePtyTransport', () => { runtimeCall.mockClear() subscriptionSendBinary.mockClear() - expect(transport.sendInput('echo one\necho two\r\n')).toBe(true) + expect(transport.sendInput('echo one\necho two\r\n', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() expect(runtimeCall).not.toHaveBeenCalled() @@ -614,7 +617,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionSendBinary.mockClear() expect(transport.claimViewport?.(101, 33)).toBe(true) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) await vi.runOnlyPendingTimersAsync() const frames = subscriptionSendBinary.mock.calls.map((call) => diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts index 6230bd00d8f..30c03745abe 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-fallback.test.ts @@ -60,7 +60,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: { onWriteUnavailable } }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) await vi.waitFor(() => expect(onWriteUnavailable).toHaveBeenCalledOnce()) @@ -103,7 +103,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: { onWriteUnavailable, onError } }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')) - expect(transport.sendInput('x')).toBe(true) + expect(transport.sendInput('x', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) await vi.waitFor(() => expect(onWriteUnavailable).toHaveBeenCalledOnce()) @@ -148,7 +148,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: { onWriteUnavailable: oldWriteUnavailable } }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')) - expect(transport.sendInput('old')).toBe(true) + expect(transport.sendInput('old', 'driving')).toBe(true) await vi.advanceTimersByTimeAsync(8) await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith( @@ -205,7 +205,7 @@ describe('createRemoteRuntimePtyTransport', () => { callbacks: {} }) await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-old')) - expect(transport.sendInput('queued-for-old')).toBe(true) + expect(transport.sendInput('queued-for-old', 'driving')).toBe(true) resolvedPaneHandle = 'terminal-new' transport.attach({ @@ -322,7 +322,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionSendBinary.mockClear() // Why: replacement input stays disabled until terminal.resolvePane proves the new handle belongs to this pane. - expect(transport.sendInput('x')).toBe(false) + expect(transport.sendInput('x', 'driving')).toBe(false) vi.advanceTimersByTime(8) const inputFrames = subscriptionSendBinary.mock.calls diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts index 53c1921fe74..cec52958852 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stale-handle-recovery.test.ts @@ -226,7 +226,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-rebind') + const pendingSend = sendInputAccepted('sent-before-rebind', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.send' })) ) @@ -293,7 +293,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-close') + const pendingSend = sendInputAccepted('sent-before-close', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.send' })) ) @@ -390,7 +390,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-stream-end') + const pendingSend = sendInputAccepted('sent-before-stream-end', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.send' }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts index 5a61a5587e6..ce689695bc0 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-sticky-replacement-policy.test.ts @@ -76,7 +76,7 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const pendingSend = sendInputAccepted('sent-before-stream-end') + const pendingSend = sendInputAccepted('sent-before-stream-end', 'driving') await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.send' }) @@ -361,7 +361,10 @@ describe('createRemoteRuntimePtyTransport', () => { if (!sendInputAccepted) { throw new Error('Expected acknowledged remote terminal input') } - const sends = Promise.all([sendInputAccepted('first'), sendInputAccepted('second')]) + const sends = Promise.all([ + sendInputAccepted('first', 'driving'), + sendInputAccepted('second', 'driving') + ]) await vi.waitFor(() => expect(hostListCalls).toBe(1)) await expect(sends).resolves.toEqual([false, false]) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts index e0b85414e3f..c3d61a8b3d3 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts @@ -466,7 +466,7 @@ describe('createRemoteRuntimePtyTransport', () => { }) expect(transport.isConnected()).toBe(false) - expect(transport.sendInput(`detached-${cycle}`)).toBe(false) + expect(transport.sendInput(`detached-${cycle}`, 'driving')).toBe(false) expect(unsubscribeByEpoch[cycle]).toHaveBeenCalledTimes(1) await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(cycle + 2)) await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1')) @@ -542,7 +542,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(disconnectedState?.phase).toBe('disconnected') expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1') expect(transport.isConnected()).toBe(false) - expect(transport.sendInput('must not reach a stale socket')).toBe(false) + expect(transport.sendInput('must not reach a stale socket', 'driving')).toBe(false) expect(onError).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(5 * 60_000) expect(runtimeSubscribe).toHaveBeenCalledTimes(callsAtCutoff) @@ -591,7 +591,7 @@ describe('createRemoteRuntimePtyTransport', () => { subscriptionCallbacks?.onClose?.() await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) expect(transport.claimViewport?.(101, 33)).toBe(true) - const accepted = transport.sendInputAccepted?.('\x03') + const accepted = transport.sendInputAccepted?.('\x03', 'driving') await Promise.resolve() rejectReconnect(new Error('reconnect failed')) @@ -610,7 +610,7 @@ describe('createRemoteRuntimePtyTransport', () => { const { streamId } = latestSubscribePayload() expect(transport.claimViewport?.(101, 33)).toBe(true) - const accepted = transport.sendInputAccepted?.('x') + const accepted = transport.sendInputAccepted?.('x', 'driving') subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'end', streamId } diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 17d78934e53..a8e8a727ad8 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -2583,6 +2583,8 @@ export function createRemoteRuntimePtyTransport( storedCallbacks = {} }, + // Why no kind: terminal.send has no launch kind, and its query-reply kind is for mobile + // clients, so the host classifies a desktop's bytes itself. sendInput(data: string): boolean { if (!connected || !handle || recoveryBlocksIo()) { return false diff --git a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts index df997a87afc..05513b53c66 100644 --- a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts @@ -27,7 +27,7 @@ describe('sendCapturedTerminalInput', () => { data: '\r' }) ).toBe(true) - expect(transport.sendInput).toHaveBeenCalledWith('\r') + expect(transport.sendInput).toHaveBeenCalledWith('\r', 'driving') } ) diff --git a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts index 7a2780a5d32..5409d5420de 100644 --- a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts @@ -32,7 +32,7 @@ export function sendCapturedTerminalInput({ ) { return false } - const sent = capturedTransport.sendInput(data) + const sent = capturedTransport.sendInput(data, 'driving') if (sent) { onAccepted?.() } diff --git a/src/renderer/src/components/terminal-pane/terminal-copy-shortcut.test.ts b/src/renderer/src/components/terminal-pane/terminal-copy-shortcut.test.ts new file mode 100644 index 00000000000..05b363d973a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-copy-shortcut.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { dispatchTerminalShortcutAction } from './terminal-keyboard-action-dispatch' +import { resolveTerminalShortcutAction } from './terminal-shortcut-policy' +import { createTerminalNativeOnlyShortcutTracker } from './terminal-native-only-shortcut' + +type DispatchContext = Parameters[3] + +function dispatchCopy(selection: string, repeat: boolean) { + const writeTerminalClipboardText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('api', { ui: { writeTerminalClipboardText } }) + const tracker = createTerminalNativeOnlyShortcutTracker() + const event = new KeyboardEvent('keydown', { + key: 'c', + code: 'KeyC', + metaKey: true, + repeat, + cancelable: true + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Copy only reads the active pane's selection. + const manager = { + getActivePane: () => ({ terminal: { getSelection: () => selection } }) + } as PaneManager + const context: DispatchContext = { + tabId: 'tab', + worktreeId: 'workspace', + fallbackCwd: '', + expandedPaneIdRef: { current: null }, + setExpandedPane: vi.fn(), + restoreExpandedLayout: vi.fn(), + refreshPaneSizes: vi.fn(), + persistLayoutSnapshot: vi.fn(), + toggleExpandPane: vi.fn(), + setSearchOpen: vi.fn(), + focusSearchInput: vi.fn(), + searchOpenRef: { current: false }, + onRequestClosePane: vi.fn(), + onClearPaneScrollback: vi.fn(), + onSetTitle: vi.fn(), + onClearPaneTitle: vi.fn(), + paneTransportsRef: { current: new Map() }, + paneCwdRef: { current: new Map() }, + managerRef: { current: manager }, + getKeyboardSplitTelemetrySource: () => 'keyboard', + armNativeOnlyShortcut: tracker.armKeyDown + } + dispatchTerminalShortcutAction({ type: 'copySelection' }, event, manager, context) + return { + claimed: event.defaultPrevented, + copies: writeTerminalClipboardText.mock.calls.length, + releaseClaimed: tracker.consumeCompanion({ type: 'keyup', key: 'c', code: 'KeyC' }) + } +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('terminal copy shortcut', () => { + it('leaves an unselected press to the terminal', () => { + expect(dispatchCopy('', false)).toEqual({ claimed: false, copies: 0, releaseClaimed: false }) + }) + + it('copies a selection once and claims the press through its release', () => { + expect(dispatchCopy('text', false)).toEqual({ claimed: true, copies: 1, releaseClaimed: true }) + expect(dispatchCopy('text', true)).toEqual({ claimed: true, copies: 0, releaseClaimed: false }) + }) + + it('matches held and remapped copy bindings', () => { + const keybindings = { 'terminal.copySelection': ['Mod+Shift+C'] } + const chord = { + key: 'c', + code: 'KeyC', + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false + } + expect(resolveTerminalShortcutAction(chord, true, 'false', 0, false, keybindings)).toBeNull() + expect( + resolveTerminalShortcutAction( + { ...chord, shiftKey: true, repeat: true }, + true, + 'false', + 0, + false, + keybindings + ) + ).toEqual({ type: 'copySelection' }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index ee6a492f642..26e8f21d3fe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -156,7 +156,8 @@ describe('handleTerminalFileDrop', () => { { assertCurrent: expect.any(Function) } ) expect(sendInput).toHaveBeenCalledWith( - wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png') + wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png'), + 'driving' ) expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -252,7 +253,8 @@ describe('handleTerminalFileDrop', () => { { assertCurrent: expect.any(Function) } ) expect(sendInput).toHaveBeenCalledWith( - wrapTerminalBracketedPasteText('\\\\server\\share\\repo\\.orca\\drops\\logo.png') + wrapTerminalBracketedPasteText('\\\\server\\share\\repo\\.orca\\drops\\logo.png'), + 'driving' ) }) @@ -308,7 +310,7 @@ describe('handleTerminalFileDrop', () => { '/remote/repo/.orca/drops', { assertCurrent: expect.any(Function) } ) - expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ') + expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ', 'driving') }) it('keeps explicit local worktree drops local while a runtime is focused', async () => { @@ -335,7 +337,7 @@ describe('handleTerminalFileDrop', () => { }) expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() - expect(sendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(sendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') expect(focus).toHaveBeenCalled() }) @@ -382,8 +384,12 @@ describe('handleTerminalFileDrop', () => { }) expect(mocks.importExternalPathsToRuntime).not.toHaveBeenCalled() - expect(sendInput).toHaveBeenNthCalledWith(1, "'/mnt/c/Users/alice/Desktop/notes one.txt' ") - expect(sendInput).toHaveBeenNthCalledWith(2, '/home/alice/repo/README.md ') + expect(sendInput).toHaveBeenNthCalledWith( + 1, + "'/mnt/c/Users/alice/Desktop/notes one.txt' ", + 'driving' + ) + expect(sendInput).toHaveBeenNthCalledWith(2, '/home/alice/repo/README.md ', 'driving') expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -413,7 +419,7 @@ describe('handleTerminalFileDrop', () => { data: { paths: ['/Users/me/spec.pdf'], target: 'terminal' } }) - expect(sendInputAccepted).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(sendInputAccepted).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') expect(sendInput).not.toHaveBeenCalled() expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -452,7 +458,7 @@ describe('handleTerminalFileDrop', () => { expect(activeSendInput).not.toHaveBeenCalled() expect(activeFocus).not.toHaveBeenCalled() - expect(targetSendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ') + expect(targetSendInput).toHaveBeenCalledWith('/Users/me/spec.pdf ', 'driving') expect(targetFocus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-target') }) @@ -508,8 +514,8 @@ describe('handleTerminalFileDrop', () => { worktreePath: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\user\\repo' }) expect(sendInput.mock.calls).toEqual([ - ["'/mnt/c/Users/Name/My Project/file.txt' "], - ['/home/user/repo/README.md '] + ["'/mnt/c/Users/Name/My Project/file.txt' ", 'driving'], + ['/home/user/repo/README.md ', 'driving'] ]) expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -608,7 +614,7 @@ describe('handleTerminalFileDrop', () => { expectedSshTargetId: 'ssh-win', expectedSshConnectionGeneration: 4 }) - expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ') + expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ', 'driving') expect(focus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -684,6 +690,6 @@ describe('handleTerminalFileDrop', () => { data: { paths: ["/Users/me/it's here.txt"], target: 'terminal' } }) - expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ") + expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ", 'driving') }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts index 2c1c2652e87..cd42929c248 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-internal-handler.test.ts @@ -116,7 +116,10 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 2 }) - expect(sendInput.mock.calls).toEqual([['/repo/a.ts '], ["'/repo/my file.ts' "]]) + expect(sendInput.mock.calls).toEqual([ + ['/repo/a.ts ', 'driving'], + ["'/repo/my file.ts' ", 'driving'] + ]) expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() }) @@ -155,7 +158,7 @@ describe('handleInternalTerminalFileDrop', () => { expect(result).toEqual({ status: 'cancelled', reason: 'target-stale', pathCount: 1 }) expect(sendInputAccepted).toHaveBeenCalledTimes(1) - expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ', 'driving') expect(sendInput).not.toHaveBeenCalled() expect(replacementSendInput).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') @@ -187,7 +190,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ', 'driving') expect(sendInput).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() @@ -254,7 +257,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInput).toHaveBeenCalledWith('"C:\\repo\\a&b.txt" ') + expect(sendInput).toHaveBeenCalledWith('"C:\\repo\\a&b.txt" ', 'driving') }) it('uses SSH remote platform metadata for Windows internal file drops', async () => { @@ -293,7 +296,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ') + expect(sendInput).toHaveBeenCalledWith('"C:\\Remote Repo\\A&B.txt" ', 'driving') expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() }) @@ -334,7 +337,7 @@ describe('handleInternalTerminalFileDrop', () => { }) expect(result).toEqual({ status: 'pasted', pathCount: 1 }) - expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ") + expect(sendInput).toHaveBeenCalledWith("'/remote/repo/it'\\''s here.txt' ", 'driving') expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(focus).toHaveBeenCalled() }) @@ -387,7 +390,7 @@ describe('handleInternalTerminalFileDrop', () => { expect(result).toEqual({ status: 'pasted', pathCount: 1 }) expect(activeSendInput).not.toHaveBeenCalled() expect(activeFocus).not.toHaveBeenCalled() - expect(targetSendInput).toHaveBeenCalledWith('/repo/drop-target.ts ') + expect(targetSendInput).toHaveBeenCalledWith('/repo/drop-target.ts ', 'driving') expect(targetFocus).toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-target') }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts index 7fcb898d7eb..20fe46c1fda 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts @@ -48,7 +48,7 @@ describe('terminal drop path writer', () => { failureReason: 'write-rejected' }) expect(sendInputAccepted).toHaveBeenCalledTimes(1) - expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenCalledWith('/repo/a.ts ', 'driving') expect(sendInput).not.toHaveBeenCalled() }) @@ -70,7 +70,8 @@ describe('terminal drop path writer', () => { // Why: image attachment detection in terminal TUIs keys off bracketed paste // of the literal path — no shell-escaping, no trailing space. expect(sendInputAccepted).toHaveBeenCalledWith( - wrapTerminalBracketedPasteText('/repo/My Screenshot.png') + wrapTerminalBracketedPasteText('/repo/My Screenshot.png'), + 'driving' ) }) @@ -88,10 +89,11 @@ describe('terminal drop path writer', () => { targetShell: 'posix' }) - expect(sendInputAccepted).toHaveBeenNthCalledWith(1, '/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenNthCalledWith(1, '/repo/a.ts ', 'driving') expect(sendInputAccepted).toHaveBeenNthCalledWith( 2, - wrapTerminalBracketedPasteText('/repo/shot.png') + wrapTerminalBracketedPasteText('/repo/shot.png'), + 'driving' ) }) @@ -113,9 +115,10 @@ describe('terminal drop path writer', () => { // non-image path would collide with it without an explicit separator. expect(sendInputAccepted).toHaveBeenNthCalledWith( 1, - `${wrapTerminalBracketedPasteText('/repo/shot.png')} ` + `${wrapTerminalBracketedPasteText('/repo/shot.png')} `, + 'driving' ) - expect(sendInputAccepted).toHaveBeenNthCalledWith(2, '/repo/a.ts ') + expect(sendInputAccepted).toHaveBeenNthCalledWith(2, '/repo/a.ts ', 'driving') }) it('does not insert a separator between back-to-back image pastes', async () => { @@ -136,11 +139,13 @@ describe('terminal drop path writer', () => { // TUI input between the two attachments. expect(sendInputAccepted).toHaveBeenNthCalledWith( 1, - wrapTerminalBracketedPasteText('/repo/one.png') + wrapTerminalBracketedPasteText('/repo/one.png'), + 'driving' ) expect(sendInputAccepted).toHaveBeenNthCalledWith( 2, - wrapTerminalBracketedPasteText('/repo/two.png') + wrapTerminalBracketedPasteText('/repo/two.png'), + 'driving' ) }) @@ -158,7 +163,10 @@ describe('terminal drop path writer', () => { targetShell: 'posix' }) - expect(sendInputAccepted).toHaveBeenCalledWith("'/repo/a.png; touch /tmp/pwned #.png' ") + expect(sendInputAccepted).toHaveBeenCalledWith( + "'/repo/a.png; touch /tmp/pwned #.png' ", + 'driving' + ) }) it('falls back to shell escaping for image paths with Windows shell metacharacters', async () => { @@ -175,7 +183,7 @@ describe('terminal drop path writer', () => { targetShell: 'windows' }) - expect(sendInputAccepted).toHaveBeenCalledWith('"C:\\Users\\me\\Pictures\\a&b.png" ') + expect(sendInputAccepted).toHaveBeenCalledWith('"C:\\Users\\me\\Pictures\\a&b.png" ', 'driving') }) it('separates an image paste from a following image path that must be shell escaped', async () => { @@ -194,9 +202,14 @@ describe('terminal drop path writer', () => { expect(sendInputAccepted).toHaveBeenNthCalledWith( 1, - `${wrapTerminalBracketedPasteText('/repo/shot.png')} ` + `${wrapTerminalBracketedPasteText('/repo/shot.png')} `, + 'driving' + ) + expect(sendInputAccepted).toHaveBeenNthCalledWith( + 2, + "'/repo/a.png; touch /tmp/pwned #.png' ", + 'driving' ) - expect(sendInputAccepted).toHaveBeenNthCalledWith(2, "'/repo/a.png; touch /tmp/pwned #.png' ") }) it('times out dropped path writes that never receive PTY acknowledgement', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts index f8ed3766251..824a812ddb7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts @@ -72,7 +72,7 @@ export async function writeTerminalDropPathsToCapturedTarget({ ) : `${shellEscapePath(path, targetShell)} ` const writeResult = await runTerminalPasteOperationWithTimeout( - () => writeTerminalPastePtyInput(liveTransport, payload), + () => writeTerminalPastePtyInput(liveTransport, payload, 'driving'), operationTimeoutMs ) if (writeResult.timedOut) { diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts index 7b7ed78989f..45fe1ee23ab 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-route.ts @@ -99,8 +99,8 @@ export function readTerminalImeCompositionSessionDetail( export function installTerminalImeCompositionRoute(args: { terminalElement: HTMLElement | null | undefined terminal: Pick - capturedTransport: PtyTransport - getCurrentTransport: () => PtyTransport | undefined + capturedTransport: Pick + getCurrentTransport: () => Pick | undefined }): IDisposable { const terminalElement = args.terminalElement const sessions = new Map() diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts new file mode 100644 index 00000000000..5b0accad22c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-user-input-provenance.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment happy-dom +// IME and Hangul commits bypass xterm's key path and enter through `terminal.input`. Main records +// a run's first user input from xterm's user-input signal, so every commit route must carry it. +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { IDisposable } from '@xterm/xterm' +import type { PtyTransport } from './pty-transport' +import { + installTerminalImeCompositionRoute, + XTERM_COMPOSITION_SESSION_END_EVENT, + XTERM_COMPOSITION_SESSION_START_EVENT +} from './terminal-ime-composition-route' +import { IPAD_DESKTOP_MODE_UA, pretendIosWeb } from './terminal-ios-hangul-preedit-fixture' +import { installTerminalPaneInputHandling } from './terminal-pane-pane-input' +import { subscribeToTerminalInputData } from './terminal-user-input-signal' + +const MAC_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36' + +type ForwardedInput = { data: string; wasUserInput: boolean } + +const disposables: IDisposable[] = [] + +function openPane(): { + terminal: Terminal + textarea: HTMLTextAreaElement + forwarded: ForwardedInput[] +} { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal({ cols: 40, rows: 8 }) + disposables.push(terminal) + terminal.open(container) + installTerminalPaneInputHandling({ + pane: { id: 1, terminal }, + managerRef: { current: null }, + paneKittyKeyboardModesRef: { current: new Map() }, + settingsRef: { current: {} }, + imeCompositionDisposablesRef: { current: new Map() }, + imeNativeTextForwarderDisposablesRef: { current: new Map() } + }) + const forwarded: ForwardedInput[] = [] + disposables.push( + subscribeToTerminalInputData(terminal, (data, wasUserInput) => + forwarded.push({ data, wasUserInput }) + ) + ) + return { terminal, textarea: terminal.textarea!, forwarded } +} + +function key( + textarea: HTMLTextAreaElement, + type: 'keydown' | 'keypress' | 'keyup', + init: { key: string; code: string; keyCode: number } +): KeyboardEvent { + const event = new KeyboardEvent(type, { ...init, bubbles: true, cancelable: true }) + // happy-dom drops the legacy numeric fields from KeyboardEventInit; xterm's key paths read them. + Object.defineProperty(event, 'keyCode', { value: init.keyCode }) + Object.defineProperty(event, 'charCode', { value: type === 'keypress' ? init.keyCode : 0 }) + textarea.dispatchEvent(event) + return event +} + +function insertText(textarea: HTMLTextAreaElement, inputType: string, data: string | null): void { + const event = new InputEvent('input', { bubbles: true }) + Object.defineProperty(event, 'inputType', { value: inputType }) + Object.defineProperty(event, 'data', { value: data }) + Object.defineProperty(event, 'composed', { value: true }) + textarea.dispatchEvent(event) +} + +describe('IME and Hangul commits reach the PTY as user input', () => { + let originalUserAgent: PropertyDescriptor | undefined + let originalMaxTouchPoints: PropertyDescriptor | undefined + let originalGetContext: PropertyDescriptor | undefined + + beforeEach(() => { + originalUserAgent = Object.getOwnPropertyDescriptor(navigator, 'userAgent') + originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, 'maxTouchPoints') + // happy-dom has no 2d context, which the DOM renderer's WidthCache requires. + originalGetContext = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'getContext') + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: () => ({ measureText: () => ({ width: 10 }) }) + }) + }) + + afterEach(() => { + for (const disposable of disposables.splice(0)) { + disposable.dispose() + } + for (const [property, descriptor] of [ + ['userAgent', originalUserAgent], + ['maxTouchPoints', originalMaxTouchPoints] + ] as const) { + // Why delete: with no own descriptor the real value lives on the prototype, which the stub shadows. + if (descriptor) { + Object.defineProperty(navigator, property, descriptor) + } else { + Reflect.deleteProperty(navigator, property) + } + } + if (originalGetContext) { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', originalGetContext) + } else { + Reflect.deleteProperty(HTMLCanvasElement.prototype, 'getContext') + } + document.body.replaceChildren() + }) + + it('marks a macOS input-source substitution as user input', () => { + pretendIosWeb(0, MAC_UA) + const { textarea, forwarded } = openPane() + + const keydown = key(textarea, 'keydown', { key: ',', code: 'Comma', keyCode: 188 }) + expect(keydown.defaultPrevented).toBe(false) + textarea.value = ',' + insertText(textarea, 'insertText', ',') + key(textarea, 'keyup', { key: ',', code: 'Comma', keyCode: 188 }) + + expect(forwarded).toEqual([{ data: ',', wasUserInput: true }]) + }) + + it('marks an iPadOS Hangul syllable commit as user input', async () => { + pretendIosWeb(5, IPAD_DESKTOP_MODE_UA) + const { textarea, forwarded } = openPane() + const typeJamo = async (jamo: string, written: string, replaces: boolean) => { + if ( + !key(textarea, 'keydown', { key: jamo, code: 'KeyQ', keyCode: jamo.charCodeAt(0) }) + .defaultPrevented + ) { + key(textarea, 'keypress', { key: jamo, code: 'KeyQ', keyCode: jamo.charCodeAt(0) }) + } + if (replaces) { + textarea.value = textarea.value.slice(0, -1) + insertText(textarea, 'deleteContentBackward', null) + } + textarea.value += written + insertText(textarea, 'insertText', written) + await new Promise((resolve) => window.setTimeout(resolve, 0)) + } + + await typeJamo('ㅎ', 'ㅎ', false) + await typeJamo('ㅏ', '하', true) + await typeJamo('ㄴ', '한', true) + await typeJamo('ㄱ', 'ㄱ', false) + + expect(forwarded).toEqual([{ data: '한', wasUserInput: true }]) + }) + + it('marks a composition the route delivers after a pane switch as user input', () => { + const { terminal, forwarded } = openPane() + const transport: Pick = { getPtyId: () => 'pty-1' } + disposables.push( + installTerminalImeCompositionRoute({ + terminalElement: terminal.element!, + terminal, + capturedTransport: transport, + getCurrentTransport: () => transport + }) + ) + const session = (type: string, data?: string): CustomEvent => + new CustomEvent(type, { + bubbles: true, + cancelable: true, + detail: { id: 1, data, dataPendingReconciliation: false } + }) + + terminal.element!.dispatchEvent(session(XTERM_COMPOSITION_SESSION_START_EVENT)) + terminal.element!.dispatchEvent(session(XTERM_COMPOSITION_SESSION_END_EVENT, '한')) + + expect(forwarded).toEqual([{ data: '한', wasUserInput: true }]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-input-mode-reset.ts b/src/renderer/src/components/terminal-pane/terminal-input-mode-reset.ts new file mode 100644 index 00000000000..479da70f65d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-input-mode-reset.ts @@ -0,0 +1,18 @@ +import { resetWebRuntimeTerminalInputModes } from '@/runtime/web-runtime-session' +import { resetPtyRendererInputModes } from './pty-buffer-serializer' + +/** + * Reset Terminal: grounds the input modes an app left armed (Kitty keyboard, + * mouse, bracketed paste, focus, alt screen, cursor keys) in the pane and in the + * host's models, so a reattach does not re-arm them. Each side grounds its own + * records; an older host rejects the request and only the pane is grounded. + */ +export function resetTerminalInputModes(ptyId: string | null): void { + if (!ptyId) { + return + } + resetPtyRendererInputModes(ptyId) + if (!resetWebRuntimeTerminalInputModes(ptyId)) { + window.api.pty.resetInputModes(ptyId) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts index 5f65a429922..42f032b17f4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts @@ -210,7 +210,7 @@ describe('the iPadOS Hangul path alongside everything else', () => { pretendIosWeb() const rig = openIosTerminal() const sent: string[] = [] - writeTerminalPastePtyInput({ sendInput: (data) => (sent.push(data), true) }, 'echo ') + writeTerminalPastePtyInput({ sendInput: (data) => (sent.push(data), true) }, 'echo ', 'driving') expect(sent).toEqual(['echo ']) await typeHangeul(rig) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts index feb3eb4b7de..ce0c10694f3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts @@ -79,10 +79,6 @@ export function dispatchTerminalShortcutAction( event.stopImmediatePropagation() return } - if (event.repeat) { - return - } - if (action.type === 'copySelection') { const pane = manager.getActivePane() ?? manager.getPanes()[0] if (!pane || !pane.terminal.getSelection()) { @@ -90,10 +86,16 @@ export function dispatchTerminalShortcutAction( } event.preventDefault() event.stopImmediatePropagation() - void copyTerminalSelection({ - terminal: pane.terminal, - writeClipboardText: window.api.ui.writeTerminalClipboardText - }).catch(() => {}) + if (!event.repeat) { + armNativeOnlyShortcut(event) + void copyTerminalSelection({ + terminal: pane.terminal, + writeClipboardText: window.api.ui.writeTerminalClipboardText + }).catch(() => {}) + } + return + } + if (event.repeat) { return } if (action.type === 'toggleSearch') { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts index d569e265098..385c07cf7c0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts @@ -3,22 +3,12 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/ter import type { PtyPaneStartup } from './pty-connection-types' import type { PtyTransport } from './pty-transport' import type { PaneCwdMap } from './resolve-split-cwd' -import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { RESET_KITTY_KEYBOARD_PROTOCOL } from '../../../../shared/terminal-mode-reset-profiles' import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry' import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' import { resolveLocalhostHttpLinkDisplayUrl } from '@/lib/http-link-routing' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { PRIMARY_SELECTION_MAX_LENGTH } from '@/lib/primary-selection' -/** Writes a transport-agnostic interrupt reset without running xterm work inline. */ -export function resetTerminalKeyboardProtocolAfterInterrupt(terminal: Terminal): void { - writeTerminalOutput(terminal, RESET_KITTY_KEYBOARD_PROTOCOL, { - foreground: true, - latencySensitive: false - }) -} - export function recordRuntimeCreatedTerminalPaneSplit( createdPane: unknown, args: { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts index 295b7e35c53..b27900d2097 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts @@ -89,7 +89,7 @@ export const executeTerminalPaneMenuPasteText = async ( const execution = await executeTerminalPastePlan(plan, { pasteText: (pasteText, pasteOptions) => pasteTerminalText(pane.terminal, pasteText, pasteOptions), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: () => isPanePasteTargetMounted(context, pane, transport, ptyId), canContinue: () => isPanePasteTargetMounted(context, pane, transport, ptyId) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts index b639d3b6ef8..32ee2639029 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts @@ -30,10 +30,9 @@ import { } from './xterm-bypass-policy' import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent' import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' -import { resetTerminalKeyboardProtocolAfterInterrupt } from './terminal-pane-lifecycle-primitives' type PaneInputContext = { - pane: ManagedPane + pane: Pick managerRef: React.RefObject paneKittyKeyboardModesRef: UseTerminalPaneLifecycleDeps['paneKittyKeyboardModesRef'] settingsRef: React.RefObject | null | undefined> @@ -141,7 +140,6 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi if (event.type === 'keydown') { pendingTerminalInterruptKeyup = true pane.terminal.input(TERMINAL_INTERRUPT_INPUT) - resetTerminalKeyboardProtocolAfterInterrupt(pane.terminal) } else { pendingTerminalInterruptKeyup = false } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts b/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts index e436bd2cc6a..309c42dc18d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts @@ -93,7 +93,7 @@ export function createTerminalPanePasteExecution( const execution = await executeTerminalPastePlan(plan, { pasteText: (pasteText, pasteOptions) => pasteTerminalText(pane.terminal, pasteText, pasteOptions), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: () => { if (!isPanePasteTargetMounted(pane, transport, ptyId)) { return false diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts index b36f0d06f65..f08926b2c81 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts @@ -54,7 +54,13 @@ describe('terminal paste operation ordering', () => { pasteText: () => {}, writePty, yieldToEventLoop: async () => { - startupDraft ??= sendAgentDraftPasteContent(null, 'pty-1', 'GENERATED_CONTEXT', writePty) + startupDraft ??= sendAgentDraftPasteContent( + null, + 'pty-1', + 'GENERATED_CONTEXT', + 'launch', + writePty + ) } }) await startupDraft @@ -80,6 +86,7 @@ describe('terminal paste operation ordering', () => { null, 'pty-1', 'G'.repeat(64 * 1024 + 1), + 'launch', async (data) => { writes.push({ owner: 'startup', data }) if (data === BRACKETED_PASTE_START) { diff --git a/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts b/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts index f92427e4729..4f3549a15ee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-programmatic-text-paste.ts @@ -66,7 +66,7 @@ export function handleTerminalProgrammaticTextPaste({ .then((plan) => executeTerminalPastePlan(plan, { pasteText: (text, options) => pasteTerminalText(pane.terminal, text, options), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: () => isTerminalPanePasteTargetCurrent({ manager: getManager(), diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts index e501c50402d..5a587c6acc9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.test.ts @@ -8,21 +8,21 @@ describe('terminal PTY paste writer', () => { const sendInputAccepted = vi.fn().mockResolvedValue(true) await expect( - writeTerminalPastePtyInput({ sendInput, sendInputAccepted }, 'payload') + writeTerminalPastePtyInput({ sendInput, sendInputAccepted }, 'payload', 'driving') ).resolves.toBe(true) - expect(sendInputAccepted).toHaveBeenCalledWith('payload') + expect(sendInputAccepted).toHaveBeenCalledWith('payload', 'driving') expect(sendInput).not.toHaveBeenCalled() }) it('falls back to queued PTY writes when acknowledged writes are unavailable', () => { const sendInput = vi.fn().mockReturnValue(true) - expect(writeTerminalPastePtyInput({ sendInput }, 'payload')).toBe(true) - expect(sendInput).toHaveBeenCalledWith('payload') + expect(writeTerminalPastePtyInput({ sendInput }, 'payload', 'driving')).toBe(true) + expect(sendInput).toHaveBeenCalledWith('payload', 'driving') }) it('rejects writes without a transport', () => { - expect(writeTerminalPastePtyInput(undefined, 'payload')).toBe(false) + expect(writeTerminalPastePtyInput(undefined, 'payload', 'driving')).toBe(false) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts index dd7e257615a..d1fffbc76fb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts @@ -1,15 +1,17 @@ import type { PtyTransport } from './pty-transport' +import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' type TerminalPastePtyWriter = Pick export function writeTerminalPastePtyInput( transport: TerminalPastePtyWriter | undefined, - data: string + data: string, + inputKind: TerminalInputKind ): boolean | Promise { if (!transport) { return false } // Why: paste chunking must respect PTY backpressure. sendInput only queues // local writes, while sendInputAccepted resolves after the PTY accepts them. - return transport.sendInputAccepted?.(data) ?? transport.sendInput(data) + return transport.sendInputAccepted?.(data, inputKind) ?? transport.sendInput(data, inputKind) } diff --git a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts index 7759e8802bf..11c0449bd96 100644 --- a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.test.ts @@ -40,7 +40,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(true) - expect(sendInput).toHaveBeenCalledWith('git status\r') + expect(sendInput).toHaveBeenCalledWith('git status\r', 'driving') expect(pane.terminal.focus).toHaveBeenCalledOnce() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') }) @@ -62,7 +62,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(false) - expect(sendInput).toHaveBeenCalledWith('npm test') + expect(sendInput).toHaveBeenCalledWith('npm test', 'driving') expect(pane.terminal.focus).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).not.toHaveBeenCalled() }) @@ -85,7 +85,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(true) - expect(sendInput).toHaveBeenCalledWith('cd packages; bun run build; cd ..\r') + expect(sendInput).toHaveBeenCalledWith('cd packages; bun run build; cd ..\r', 'driving') expect(pane.terminal.focus).toHaveBeenCalledOnce() }) @@ -107,7 +107,7 @@ describe('sendTerminalQuickCommandToPane', () => { }) expect(sent).toBe(true) - expect(sendInput).toHaveBeenCalledWith('echo one; echo two') + expect(sendInput).toHaveBeenCalledWith('echo one; echo two', 'driving') expect(pane.terminal.focus).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts index 39d49d02c86..5c8d3622b00 100644 --- a/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-quick-command-dispatch.ts @@ -5,6 +5,7 @@ import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' +import type { PtyTransport } from './pty-transport-types' type QuickCommandPane = { leafId: string @@ -13,9 +14,7 @@ type QuickCommandPane = { } } -type QuickCommandTransport = { - sendInput: (data: string) => boolean -} +type QuickCommandTransport = Pick export function sendTerminalQuickCommandToPane({ command, @@ -36,7 +35,8 @@ export function sendTerminalQuickCommandToPane({ } const sent = transport.sendInput( - buildTerminalQuickCommandInput(flattenTerminalQuickCommand(command)) + buildTerminalQuickCommandInput(flattenTerminalQuickCommand(command)), + 'driving' ) if (sent) { recordTerminalUserInputForLeaf(tabId, pane.leafId) diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index ce6a3db257c..70a3c4d2668 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' import { + CONFIRMED_SHELL_MODE_RESET, POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, - RESET_KITTY_KEYBOARD_PROTOCOL, - RESET_TERMINAL_CURSOR_STYLE + RESET_KITTY_KEYBOARD_PROTOCOL } from '../../../../shared/terminal-mode-reset-profiles' const OLD_REATTACH_RESET_WITHOUT_CURSOR_STYLE = '\x1b[?25h\x1b[?1004l' @@ -74,10 +74,11 @@ function writeTerminal(term: Terminal, data: string): Promise { } describe('terminal replay state reset', () => { - it('includes Kitty keyboard protocol reset in replay reset bundles', () => { + it('resets Kitty keyboard state only in the process-ended bundles', () => { expect(RESET_KITTY_KEYBOARD_PROTOCOL).toBe('\x1b[<99u\x1b[=0u') expect(POST_REPLAY_MODE_RESET).toContain(RESET_KITTY_KEYBOARD_PROTOCOL) - expect(POST_REPLAY_REATTACH_RESET).toContain(RESET_KITTY_KEYBOARD_PROTOCOL) + expect(CONFIRMED_SHELL_MODE_RESET).toContain(RESET_KITTY_KEYBOARD_PROTOCOL) + expect(POST_REPLAY_REATTACH_RESET).not.toContain(RESET_KITTY_KEYBOARD_PROTOCOL) expect(POST_REPLAY_LIVE_SNAPSHOT_RESET).not.toContain(RESET_KITTY_KEYBOARD_PROTOCOL) }) @@ -137,7 +138,7 @@ describe('terminal replay state reset', () => { } }) - it('clears active-buffer Kitty keyboard state after live reattach replay', async () => { + it("keeps a live app's Kitty keyboard state through the live reattach reset", async () => { const term = new Terminal({ cols: 80, rows: 24, @@ -147,25 +148,14 @@ describe('terminal replay state reset', () => { try { await writeTerminal(term, '\x1b[=31u\x1b[>15u') - expect(readKittyKeyboardState(term)).toMatchObject({ - flags: 15, - mainStack: [31] - }) - await writeTerminal(term, POST_REPLAY_REATTACH_RESET) - // Why: after renderer reattach, the next Ctrl+C must not inherit a stale - // Kitty CSI-u encoder state from the replayed TUI snapshot. - expect(readKittyKeyboardState(term)).toMatchObject({ - flags: 0, - mainFlags: 0, - mainStack: [] - }) + expect(readKittyKeyboardState(term)).toMatchObject({ flags: 15, mainStack: [31] }) } finally { term.dispose() } }) - it('clears active-buffer Kitty keyboard state with the idle-agent reset sequence', async () => { + it('clears active-buffer Kitty keyboard state with the confirmed-shell reset', async () => { const term = new Terminal({ cols: 80, rows: 24, @@ -175,14 +165,7 @@ describe('terminal replay state reset', () => { try { await writeTerminal(term, '\x1b[=31u\x1b[>15u') - expect(readKittyKeyboardState(term)).toMatchObject({ - flags: 15, - mainStack: [31] - }) - - await writeTerminal(term, `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`) - // Why: this is the exact reset emitted when a native Windows agent turn - // completes, so the next Backspace/Enter must not inherit CSI-u encoding. + await writeTerminal(term, CONFIRMED_SHELL_MODE_RESET) expect(readKittyKeyboardState(term)).toMatchObject({ flags: 0, mainFlags: 0, diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 3ec1fea161d..98f47941bb7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -100,17 +100,17 @@ export function resolveTerminalShortcutAction( return { type: 'switchInputSource' } } - // Why: held select-all keydowns must remain claimed until keyup so Kitty - // event reporting cannot encode their repeat or release into the PTY. + // Why: held select-all and copy keydowns must remain claimed until keyup so + // Kitty event reporting cannot encode their repeat or release into the PTY. if (keybindingMatchesAction('terminal.selectAll', event, platform, keybindings)) { return { type: 'selectAll' } } - if (!event.repeat) { - if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { - return { type: 'copySelection' } - } + if (keybindingMatchesAction('terminal.copySelection', event, platform, keybindings)) { + return { type: 'copySelection' } + } + if (!event.repeat) { if (keybindingMatchesAction('terminal.search', event, platform, keybindings)) { return { type: 'toggleSearch' } } diff --git a/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts new file mode 100644 index 00000000000..fad9f9d7c20 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import { + LEGACY_PTY_ALLOCATION_HINT, + LEGACY_TERMINAL_PROCESS_LIMIT_HINT, + PTY_ALLOCATION_HINT, + TERMINAL_PROCESS_LIMIT_HINT +} from '../../../../shared/terminal-spawn-error-copy' + +// Why a locale stand-in: English fallbacks equal the host copy, so only a translation shows the swap. +vi.mock('@/i18n/i18n', () => ({ + translate: (key: string, fallback: string) => + ({ + 'auto.components.terminal.pane.TerminalErrorToast.ptyAllocationLimit': + '系统无法再分配 pty 设备。', + 'auto.components.terminal.pane.TerminalErrorToast.terminalProcessLimit': + '系统无法再启动终端进程。' + })[key] ?? fallback +})) + +import { + localizeTerminalSpawnHints, + withoutTerminalSpawnIssueRequest +} from './terminal-spawn-error-display' + +const RAW = + 'Failed to spawn shell "/bin/zsh": node-pty: open_slave failed: EMFILE (errno 24, Too many open files) (shell: /bin/zsh). If this persists, please file an issue.' + +describe('localizeTerminalSpawnHints', () => { + it('translates the pty allocation hint and keeps the raw diagnostic', () => { + expect(localizeTerminalSpawnHints(`${PTY_ALLOCATION_HINT}\n${RAW}`)).toBe( + `系统无法再分配 pty 设备。\n${RAW}` + ) + }) + + it('translates the terminal process limit hint', () => { + expect(localizeTerminalSpawnHints(TERMINAL_PROCESS_LIMIT_HINT)).toBe('系统无法再启动终端进程。') + }) + + it('translates the multi-line hints an older host publishes', () => { + expect(localizeTerminalSpawnHints(`${LEGACY_PTY_ALLOCATION_HINT} ${RAW}`)).toBe( + `系统无法再分配 pty 设备。 ${RAW}` + ) + expect(localizeTerminalSpawnHints(LEGACY_TERMINAL_PROCESS_LIMIT_HINT)).toBe( + '系统无法再启动终端进程。' + ) + }) + + it('leaves unrelated errors untouched', () => { + expect(localizeTerminalSpawnHints('Paste failed.')).toBe('Paste failed.') + }) +}) + +describe('withoutTerminalSpawnIssueRequest', () => { + it('drops the host issue request and keeps the diagnostic', () => { + expect(withoutTerminalSpawnIssueRequest(RAW)).toBe( + 'Failed to spawn shell "/bin/zsh": node-pty: open_slave failed: EMFILE (errno 24, Too many open files) (shell: /bin/zsh).' + ) + }) + + it('drops the request from an error before another pane error', () => { + expect(withoutTerminalSpawnIssueRequest(`${RAW}\nPaste failed.`)).toBe( + 'Failed to spawn shell "/bin/zsh": node-pty: open_slave failed: EMFILE (errno 24, Too many open files) (shell: /bin/zsh).\nPaste failed.' + ) + }) + + it('keeps the same words when they are part of the diagnostic', () => { + const error = `Command printed: If this persists, please file an issue.\nMore detail follows.` + expect(withoutTerminalSpawnIssueRequest(error)).toBe(error) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts new file mode 100644 index 00000000000..faa79292388 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-spawn-error-display.ts @@ -0,0 +1,39 @@ +import { translate } from '@/i18n/i18n' +import { + LEGACY_PTY_ALLOCATION_HINT, + LEGACY_TERMINAL_PROCESS_LIMIT_HINT, + PTY_ALLOCATION_HINT, + TERMINAL_PROCESS_LIMIT_HINT, + TERMINAL_SPAWN_ISSUE_REQUEST +} from '../../../../shared/terminal-spawn-error-copy' + +/** Swaps the host's English resource-limit hints for the viewer's locale. */ +export function localizeTerminalSpawnHints(error: string): string { + const ptyAllocation = translate( + 'auto.components.terminal.pane.TerminalErrorToast.ptyAllocationLimit', + PTY_ALLOCATION_HINT + ) + const processLimit = translate( + 'auto.components.terminal.pane.TerminalErrorToast.terminalProcessLimit', + TERMINAL_PROCESS_LIMIT_HINT + ) + // Why a replacer: a translation containing `$&` would otherwise be read as a substitution. + return error + .replaceAll(LEGACY_PTY_ALLOCATION_HINT, () => ptyAllocation) + .replaceAll(PTY_ALLOCATION_HINT, () => ptyAllocation) + .replaceAll(LEGACY_TERMINAL_PROCESS_LIMIT_HINT, () => processLimit) + .replaceAll(TERMINAL_PROCESS_LIMIT_HINT, () => processLimit) +} + +/** Drops the host's plain-text issue request where the toast renders its own linked one. */ +export function withoutTerminalSpawnIssueRequest(error: string): string { + const suffix = ` ${TERMINAL_SPAWN_ISSUE_REQUEST}` + return error + .split('\n') + .map((line) => + line.includes('Failed to spawn shell "') && line.endsWith(suffix) + ? line.slice(0, -suffix.length) + : line + ) + .join('\n') +} diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts index 3a2307cd87d..913c8b6cfdc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-paste.ts @@ -47,7 +47,7 @@ export async function executeTerminalStartupCommandPaste({ return executeTerminalPastePlan(plan, { pasteText: (text, options) => pasteTerminalText(pane.terminal, text, options), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'launch'), isTargetCurrent: isCurrent, canContinue: isCurrent }) diff --git a/src/renderer/src/components/terminal-pane/title-agent-identity.ts b/src/renderer/src/components/terminal-pane/title-agent-identity.ts index 8a496052335..157d082a919 100644 --- a/src/renderer/src/components/terminal-pane/title-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/title-agent-identity.ts @@ -1,3 +1,4 @@ +import { isQoderTerminalTitle } from '../../../../shared/qoder-terminal-title' import { detectAgentStatusFromTitle, isGeminiTerminalTitle, @@ -21,6 +22,7 @@ export function titleHasExplicitAgentIdentity(title: string): boolean { title.startsWith('. ') || title.startsWith('* ') || title.startsWith('\u2733') || + isQoderTerminalTitle(title) || isGeminiTerminalTitle(title) || isPiTerminalTitle(title) ) { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 155482f2163..463a3a7f415 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -26,6 +26,7 @@ import { useTerminalContextMenuTrigger } from './use-terminal-context-menu-trigg import { useAppStore } from '@/store' import { makePaneKey } from '../../../../shared/stable-pane-id' import { resolvePaneAgentSessionId } from './pane-agent-session-id' +import { resetTerminalInputModes } from './terminal-input-mode-reset' type UseTerminalPaneContextMenuDeps = { managerRef: React.RefObject @@ -68,6 +69,7 @@ type TerminalMenuState = { onEqualizePaneSizes: () => void onClosePane: () => void onClearScreen: () => void + onResetTerminal: () => void onForkAgentSession: () => Promise onContinueAgentSessionInNewSession: () => void onCopyAgentSessionContext: () => Promise @@ -209,6 +211,13 @@ export function useTerminalPaneContextMenu({ } } + const onResetTerminal = (): void => { + const pane = resolveMenuPane() + if (pane) { + resetTerminalInputModes(paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null) + } + } + const onForkAgentSession = async (): Promise => forkAgentSessionFromMenuPane(agentSessionContext, resolveMenuPane()) @@ -295,6 +304,7 @@ export function useTerminalPaneContextMenu({ onEqualizePaneSizes, onClosePane, onClearScreen, + onResetTerminal, onForkAgentSession, onContinueAgentSessionInNewSession, onCopyAgentSessionContext, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index a86341860fa..7e9e1e42b93 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -12,7 +12,6 @@ import { resolvePaneSeedCwd, resolveQueuedInitialCwd, replayLayoutWithOneShotParkIntent, - resetTerminalKeyboardProtocolAfterInterrupt, retireMountedTerminalPaneSurface, shouldDetachPaneTransportOnUnmount, splitPaneWithOneShotStartup, @@ -168,23 +167,6 @@ describe('applyTerminalPaneCloseRequest', () => { }) }) -describe('resetTerminalKeyboardProtocolAfterInterrupt', () => { - it('does not write to an xterm whose pipeline is certified dead', async () => { - const { _resetWritePipelineHealthForTests, notifyUndeliverableWrite } = - await import('@/lib/pane-manager/terminal-write-pipeline-health') - const terminal = { write: vi.fn() } - try { - notifyUndeliverableWrite(terminal, 'replay-wedged') - - resetTerminalKeyboardProtocolAfterInterrupt(terminal as never) - - expect(terminal.write).not.toHaveBeenCalled() - } finally { - _resetWritePipelineHealthForTests(terminal) - } - }) -}) - // Why: onPaneCreated uses paneOwnsQueuedStartup to decide whether a pane may spend the tab's queued // startup command. Setup/issue splits borrow the same deps.startup field for their own one-shot // payload, so a looser test would let a split pane spend a command it never runs — re-breaking diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index d5f39d6fa8a..944a2e6bbe0 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -25,7 +25,6 @@ export { mapRestoredPaneTitlesByPaneId, paneOwnsQueuedStartup, replayLayoutWithOneShotParkIntent, - resetTerminalKeyboardProtocolAfterInterrupt, resolvePaneLinkCwd, resolvePaneSeedCwd, resolveQueuedInitialCwd, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts index 5ce2bfc438e..009ab6b800a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-mobile-actions.ts @@ -196,7 +196,7 @@ export function useTerminalPaneMobileActions(controller: TerminalPaneContextCont const execution = await executeTerminalPastePlan(plan, { pasteText: (pasteText, pasteOptions) => pasteTerminalText(clickedPane.terminal, pasteText, pasteOptions), - writePty: (data) => writeTerminalPastePtyInput(transport, data), + writePty: (data) => writeTerminalPastePtyInput(transport, data, 'driving'), isTargetCurrent: targetStillMounted, canContinue: targetStillMounted }) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts index 265a80bf47e..c08ff8316c1 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + isAppOwnedCopyChord, shouldBypassXtermKeyboardEvent, shouldPreventDefaultTerminalImeCandidateKey, shouldSuppressTerminalImeKeyboardEvent @@ -19,12 +20,32 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => { ).toBe(true) }) - it('bubbles Cmd+C even with no selection (no-op copy is harmless on macOS)', () => { + it('bubbles Cmd+C in a plain shell with no selection', () => { expect( shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC', metaKey: true }), noSel) ).toBe(true) }) + it.each([1, 2, 3, 8, 31])('lets unselected Cmd+C reach Kitty flags %i', (flags) => { + for (const type of ['keydown', 'keyup']) { + for (const repeat of [false, true]) { + const chord = event({ type, key: 'c', code: 'KeyC', metaKey: true, repeat }) + expect(shouldBypassXtermKeyboardEvent(chord, { ...noSel, kittyKeyboardFlags: flags })).toBe( + false + ) + expect(shouldBypassXtermKeyboardEvent(chord, { ...opts, kittyKeyboardFlags: flags })).toBe( + true + ) + expect( + shouldBypassXtermKeyboardEvent( + { ...chord, defaultPrevented: true }, + { ...noSel, kittyKeyboardFlags: flags } + ) + ).toBe(true) + } + } + }) + it('bubbles Cmd+V so web clients receive the native paste event', () => { expect( shouldBypassXtermKeyboardEvent(event({ key: 'v', code: 'KeyV', metaKey: true }), noSel) @@ -173,6 +194,24 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => { }) }) +describe('isAppOwnedCopyChord', () => { + const cmdC = event({ key: 'c', code: 'KeyC', metaKey: true }) + const kittyApp = { isMac: true, hasSelection: false, kittyKeyboardFlags: 1 } + + it('gives unselected macOS Cmd+C to an app that negotiated Kitty reporting', () => { + expect(isAppOwnedCopyChord(cmdC, kittyApp)).toBe(true) + }) + + it('keeps Orca copy for a selection, a plain shell, other chords, and other platforms', () => { + expect(isAppOwnedCopyChord(cmdC, { ...kittyApp, hasSelection: true })).toBe(false) + expect(isAppOwnedCopyChord(cmdC, { ...kittyApp, kittyKeyboardFlags: 0 })).toBe(false) + expect(isAppOwnedCopyChord(cmdC, { ...kittyApp, kittyKeyboardFlags: undefined })).toBe(false) + expect(isAppOwnedCopyChord({ ...cmdC, shiftKey: true }, kittyApp)).toBe(false) + const ctrlShiftC = event({ key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }) + expect(isAppOwnedCopyChord(ctrlShiftC, { ...kittyApp, isMac: false })).toBe(false) + }) +}) + describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => { const idle = { isMac: true, diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 2ab8f27f4f1..977a1731e1e 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -282,6 +282,23 @@ function matchesClipboardBinding( return keybindingMatchesInput(binding, event, platform) } +/** + * On macOS an unselected Cmd+C belongs to an app that negotiated Kitty keyboard + * reporting: apps like Codex capture the mouse, so their highlight is never an + * xterm selection. Without negotiation, unselected Cmd+C still sends nothing. + */ +export function isAppOwnedCopyChord( + event: XtermBypassEvent, + options: Pick +): boolean { + return ( + options.isMac && + !options.hasSelection && + (options.kittyKeyboardFlags ?? 0) !== 0 && + matchesClipboardBinding('Mod+C', event, 'darwin') + ) +} + /** * Decide whether plain Ctrl+C should bypass xterm's kitty CSI-u encoder and * be sent as ETX through Terminal.input() instead. @@ -356,6 +373,10 @@ export function shouldBypassXtermKeyboardEvent( return true } + if (isAppOwnedCopyChord(event, options)) { + return false + } + if (isMac) { // Why: window-level handlers already consume other Cmd chords before xterm // sees them in Electron. Web clients still need paste to bubble to diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx index d9799888a2e..734cef0f795 100644 --- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx @@ -6,18 +6,24 @@ import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects' const mocks = vi.hoisted(() => { const storeTabsByWorktree: Record = {} + const storeClosedRecords: Record = {} return { gate: vi.fn(), resume: vi.fn(), authority: 'none', launchStatus: vi.fn((_worktreeId: string, _provider: string): string => 'idle'), createTab: vi.fn(), - storeTabsByWorktree + storeTabsByWorktree, + storeClosedRecords } }) vi.mock('@/store', () => ({ useAppStore: Object.assign(() => mocks.authority, { - getState: () => ({ activeWorktreeId: 'wt-1', tabsByWorktree: mocks.storeTabsByWorktree }) + getState: () => ({ + activeWorktreeId: 'wt-1', + tabsByWorktree: mocks.storeTabsByWorktree, + closedTerminalTabTombstonesByTabId: mocks.storeClosedRecords + }) }) })) vi.mock('@/lib/worktree-agent-activation-gate', () => ({ @@ -46,6 +52,7 @@ afterEach(async () => { vi.clearAllMocks() mocks.authority = 'none' mocks.storeTabsByWorktree = {} + mocks.storeClosedRecords = {} }) function Watcher({ restored = true, hydrated = false, worktreeId = 'wt-1' } = {}): null { @@ -179,11 +186,22 @@ describe('passive terminal seeding retries until a decision applies', () => { const finishGate = deferredGate() root = createRoot(document.createElement('div')) await act(async () => root?.render()) + // closeTab empties the row and records the close in the same store write. mocks.storeTabsByWorktree = { 'wt-1': [] } + mocks.storeClosedRecords = { 'closed-tab': { worktreeId: 'wt-1', closedAt: Date.now() } } await finishGate('empty') expect(mocks.createTab).not.toHaveBeenCalled() }) + it('seeds an empty row that has no close record, which legacy data leaves', async () => { + const finishGate = deferredGate() + root = createRoot(document.createElement('div')) + await act(async () => root?.render()) + mocks.storeTabsByWorktree = { 'wt-1': [] } + await finishGate('empty') + expect(mocks.createTab).toHaveBeenCalledTimes(1) + }) + it('seeds after leaving and returning to a blocked workspace', async () => { mocks.gate.mockResolvedValue('blocked') root = createRoot(document.createElement('div')) diff --git a/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts b/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts index 8908ecefa40..d56384227cc 100644 --- a/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts +++ b/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts @@ -15,10 +15,10 @@ function readSource(relativePath: string): string { describe('Terminal auto-create wiring', () => { const source = readSource(TERMINAL_PATH) - it('derives the tombstone from the active worktree row at decision time', () => { + it('derives the tombstone from the active worktree row and its close records at decision time', () => { // Why the live store: a render-captured row goes stale while the activation check runs. expect(source).toMatch( - /Object\.hasOwn\(\s*useAppStore\.getState\(\)\.tabsByWorktree,\s*activeWorktreeId\s*\)/ + /isTerminalWorkspaceEmptiedOnPurpose\(\s*useAppStore\.getState\(\),\s*activeWorktreeId\s*\)/ ) }) diff --git a/src/renderer/src/components/terminal/initial-terminal.ts b/src/renderer/src/components/terminal/initial-terminal.ts index b895bf0cbd5..515effce58c 100644 --- a/src/renderer/src/components/terminal/initial-terminal.ts +++ b/src/renderer/src/components/terminal/initial-terminal.ts @@ -2,6 +2,7 @@ export function shouldAutoCreateInitialTerminal( renderableTabCount: number, hasPersistedTerminalState = false ): boolean { - // Why: a missing row means never initialized; an explicit empty row records that the user closed the last terminal. + // Why: desktop callers pass isTerminalWorkspaceEmptiedOnPurpose, so an empty row with no close + // record (legacy data, or a writer that is not a close) still reads as never initialized. return renderableTabCount === 0 && !hasPersistedTerminalState } diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 0de705d9df5..f91c06bd7d7 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -10,6 +10,7 @@ import { type ParkedTerminalTabWatcherSyncEntry } from './terminal-pane/terminal-parked-tab-watchers' import { useAppStore } from '@/store' +import { isTerminalWorkspaceEmptiedOnPurpose } from '../../../shared/closed-terminal-tab-tombstones' import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' import { createWorkspaceTerminalHostAuthoritySelector } from '@/lib/workspace-terminal-host-authority' import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' @@ -238,10 +239,10 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController) } // Why: the activation gate reconciles durable/live agent state first; only an actually empty, never-visited workspace receives a default shell. const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId) - // Why (main): a missing row means never initialized, an explicit empty row means the user - // closed the last terminal. Read at decision time: the row can change while the check runs. - const activeWorktreeHasTerminalState = Object.hasOwn( - useAppStore.getState().tabsByWorktree, + // Why (main): only a workspace emptied by a recorded close stays empty; an empty row with no + // record is unknown and seeds. Read at decision time: the row can change while the check runs. + const activeWorktreeHasTerminalState = isTerminalWorkspaceEmptiedOnPurpose( + useAppStore.getState(), activeWorktreeId ) if (shouldAutoCreateInitialTerminal(renderableTabCount, activeWorktreeHasTerminalState)) { diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts index a1e82633f98..57964af2e99 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge-close-tombstones.test.ts @@ -33,32 +33,28 @@ function sessionState(overrides: Partial = {}): Workspace } as WorkspaceSessionState } +// Records ride on `current` here for brevity; the caller passes the renderer's read-only mirror. function merge( current: WorkspaceSessionState, remote: WorkspaceSessionState, - liveTabs: AppState['tabsByWorktree'] = {}, - remoteRevision?: number + liveTabs: AppState['tabsByWorktree'] = {} ): WorkspaceSessionState { return mergeDirectSshRemoteWorkspaceSession( - current, + { ...current, closedTerminalTabTombstonesByTabId: undefined }, remote, new Set([WORKTREE]), liveTabs, new Set(), undefined, - remoteRevision + current.closedTerminalTabTombstonesByTabId ) } -function tombstone(tabId: string, ackRevision?: number): WorkspaceSessionState { +function tombstone(tabId: string): WorkspaceSessionState { return sessionState({ tabsByWorktree: { [WORKTREE]: [] }, closedTerminalTabTombstonesByTabId: { - [tabId]: { - closedAt: Date.now(), - worktreeId: WORKTREE, - ...(ackRevision ? { ackRevision } : {}) - } + [tabId]: { closedAt: Date.now(), worktreeId: WORKTREE } } }) } @@ -77,7 +73,7 @@ describe('direct-SSH pull merge: closed-tab tombstones', () => { remoteSessionIdsByTabId: { toString: 'session-1' } }) - const merged = merge(current, remote, {}, 2) + const merged = merge(current, remote, {}) expect(merged.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['toString']) expect(Object.hasOwn(merged.terminalLayoutsByTabId, 'toString')).toBe(true) @@ -96,7 +92,7 @@ describe('direct-SSH pull merge: closed-tab tombstones', () => { }) const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [terminalTab('tab-x')] } }) - const merged = merge(current, remote, {}, 2) + const merged = merge(current, remote, {}) expect(merged.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['tab-x']) }) @@ -109,12 +105,11 @@ describe('direct-SSH pull merge: closed-tab tombstones', () => { remoteSessionIdsByTabId: { ghost: 'pty-1' } }) - const merged = merge(tombstone('ghost'), remote, { [WORKTREE]: [] }, 3) + const merged = merge(tombstone('ghost'), remote, { [WORKTREE]: [] }) expect(merged.tabsByWorktree[WORKTREE]).toEqual([]) expect(merged.terminalLayoutsByTabId.ghost).toBeUndefined() expect(merged.remoteSessionIdsByTabId?.ghost).toBeUndefined() - expect(merged.closedTerminalTabTombstonesByTabId?.ghost).toBeDefined() }) it('does not re-add a tombstoned tab through the host-unknown branch', () => { @@ -127,7 +122,7 @@ describe('direct-SSH pull merge: closed-tab tombstones', () => { }) const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [] } }) - const merged = merge(current, remote, {}, 3) + const merged = merge(current, remote, {}) expect(merged.tabsByWorktree[WORKTREE]).toEqual([]) }) @@ -143,7 +138,7 @@ describe('direct-SSH pull merge: closed-tab tombstones', () => { activeTabIdByWorktree: { [WORKTREE]: 'ghost' } }) - const merged = merge(tombstone('ghost'), remote, { [WORKTREE]: [] }, 3) + const merged = merge(tombstone('ghost'), remote, { [WORKTREE]: [] }) expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['survivor']) }) @@ -156,7 +151,7 @@ describe('direct-SSH pull merge: closed-tab tombstones', () => { }) const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [live] } }) - const merged = merge(current, remote, { [WORKTREE]: [live] }, 3) + const merged = merge(current, remote, { [WORKTREE]: [live] }) expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toEqual(['live']) }) @@ -169,24 +164,20 @@ describe('direct-SSH pull merge: closed-tab tombstones', () => { const current = sessionState({ tabsByWorktree: { [WORKTREE]: [agent, closedElsewhere] } }) const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [agent] } }) - const merged = merge(current, remote, { [WORKTREE]: [agent, closedElsewhere] }, 3) + const merged = merge(current, remote, { [WORKTREE]: [agent, closedElsewhere] }) expect(merged.tabsByWorktree[WORKTREE].map((tab) => tab.id)).toContain('closed-elsewhere') }) - it('retires the tombstone once a newer snapshot stops listing the tab', () => { - const remote = sessionState({ tabsByWorktree: { [WORKTREE]: [] } }) - - const merged = merge(tombstone('ghost', 3), remote, { [WORKTREE]: [] }, 4) + it('neither retires nor writes back a record once the host stops listing the tab', () => { + // Main alone writes records; an acknowledgement used to delete them here, which made a workspace + // the user emptied read as never initialized as soon as the host caught up. + const current = tombstone('ghost') + const merged = merge(current, sessionState({ tabsByWorktree: { [WORKTREE]: [] } }), { + [WORKTREE]: [] + }) expect(merged.closedTerminalTabTombstonesByTabId).toBeUndefined() - }) - - it('keeps the tombstone when the snapshot omits the worktree entirely', () => { - // A snapshot that says nothing about the worktree — including one whose path never resolved — - // is not evidence the host saw the close. - const merged = merge(tombstone('ghost', 3), sessionState(), {}, 4) - - expect(merged.closedTerminalTabTombstonesByTabId?.ghost).toBeDefined() + expect(current.closedTerminalTabTombstonesByTabId?.ghost).toBeDefined() }) }) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts b/src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts index 3764181e71c..ff817913da4 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge-closed-terminal-tombstone.test.ts @@ -15,9 +15,10 @@ import { useAppStore } from '@/store' * The closed-last-terminal tombstone across a direct-SSH reconnect, end to end. * * The unit-level rule lives in remote-workspace-session-merge-local-survival.test.ts; this asserts - * what that rule is FOR. `Object.hasOwn(tabsByWorktree, worktreeId)` has to keep meaning one thing - * — the merge dropping the key turned "the user closed the last terminal" into "never - * initialized", and the seeding pass then handed the terminal back on every reconnect. + * what that rule is FOR. An empty row plus a close record has to keep meaning one thing — the + * merge dropping the key, or the re-hydration dropping the record, turns "the user closed the last + * terminal" into "never initialized", and the seeding pass then hands the terminal back on every + * reconnect. */ const initialAppStoreState = useAppStore.getState() @@ -26,9 +27,13 @@ afterEach(() => { useAppStore.setState(initialAppStoreState, true) }) -/** The state a workspace lands in once its last terminal is closed: an explicit empty row. */ +/** The state a workspace lands in once its last terminal is closed: an explicit empty row, and + * the close record the renderer mirrors from main. */ function seedClosedLastTerminal(worktreeId: string): void { - useAppStore.setState({ tabsByWorktree: { [worktreeId]: [] } }) + useAppStore.setState({ + tabsByWorktree: { [worktreeId]: [] }, + closedTerminalTabTombstonesByTabId: { closed: { closedAt: Date.now(), worktreeId } } + }) expect(useAppStore.getState().reconcileWorktreeTabModel(worktreeId).renderableTabCount).toBe(0) } diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index 964c942e0ad..f8bbb0d5ab1 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -1,6 +1,9 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { reconcileClosedTerminalTabTombstones } from '../../../shared/closed-terminal-tab-tombstones' +import { + hasClosedTerminalTabRecord, + type ClosedTerminalTabTombstonesByTabId +} from '../../../shared/closed-terminal-tab-tombstones' import type { ExecutionHostId } from '../../../shared/execution-host' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' import { splitWorktreeId } from '../../../shared/worktree/id' @@ -33,7 +36,7 @@ export function mergeDirectSshRemoteWorkspaceSession( liveTabsByWorktree: AppState['tabsByWorktree'], preserveLocalTerminalTabIds: ReadonlySet, replaceExecutionHostId?: ExecutionHostId, - remoteRevision?: number, + closedTabRecords?: ClosedTerminalTabTombstonesByTabId, preserveLocalLayoutTabIds: ReadonlySet = new Set() ): WorkspaceSessionState { // Live tabs across the worktrees this snapshot replaces. Close-suppression consults it so a tab @@ -45,7 +48,7 @@ export function mergeDirectSshRemoteWorkspaceSession( // (terminal-tab-close.ts), so a tombstoned id is not live anywhere; // 2. isSuppressedByClose matches the tombstone's own worktreeId, so it cannot reach another // workspace's tab even if an id somehow recurred; - // 3. only closeReason === 'user' ever writes a tombstone. + // 3. only a tab close writes a record; nothing infers one from a tab's absence. // A final sweep of suppression over the assembled tabsByWorktree — including worktrees // omitTargetWorktrees passes through verbatim — is still deliberately absent. const currentTabsById = new Map( @@ -56,10 +59,10 @@ export function mergeDirectSshRemoteWorkspaceSession( const locallyPreservedTabIds = new Set() const localTabsFor = (worktreeId: string): TerminalTab[] => liveTabsByWorktree[worktreeId] ?? current.tabsByWorktree[worktreeId] ?? [] - // Why presence and not length: an explicit empty row is the record that the user closed the last - // terminal (initial-terminal.ts), and localTabsFor cannot tell it from an absent one. Admitting - // only non-empty rows dropped the key, which reads downstream as "never initialized" and seeds a - // fresh terminal on every reconnect. + // Why presence and not length: an explicit empty row, with a close record for the worktree, is how + // a workspace the user emptied reads (initial-terminal.ts), and localTabsFor cannot tell it from an + // absent one. Admitting only non-empty rows dropped the key, which reads downstream as "never + // initialized" and seeds a fresh terminal on every reconnect. const hasLocalTabsRow = (worktreeId: string): boolean => Object.hasOwn(liveTabsByWorktree, worktreeId) || Object.hasOwn(current.tabsByWorktree, worktreeId) @@ -116,36 +119,13 @@ export function mergeDirectSshRemoteWorkspaceSession( .filter(([tabId]) => remoteKnownTabIds.has(tabId)) .map(([, sessionId]) => sessionId) ) - // The other half of the trade below. Absence still cannot say "closed", so this says it instead: - // a tab THIS client watched the user close, whose close never reached the host. Only ids the user - // closed are ever in here, and only until the host's own snapshot stops listing them. - const closedTerminalTabTombstonesByTabId = reconcileClosedTerminalTabTombstones({ - tombstones: current.closedTerminalTabTombstonesByTabId, - acknowledgedWorktreeIds: new Set( - [...replaceWorktreeIds].filter((worktreeId) => - Object.hasOwn(remote.tabsByWorktree, worktreeId) - ) - ), - hostKnownTabIds: remoteKnownTabIds, - hostRevision: remoteRevision, - now: Date.now() - }) - // Why Object.hasOwn and not `in`: the map is a plain object from Object.fromEntries, so `in` - // answers true for every Object.prototype key on an EMPTY map — a host tab whose id is - // `toString` would be filtered, blocked from the host-unknown branch, and stripped of its layout - // and session id. Tab ids are validated only as non-empty and colon-free, and createTab honours - // caller-supplied id hints, so that id is reachable. This is the one place either direction could - // delete a tab the user never closed. - // Why the worktree comparison: the tombstone already carries the worktree it was closed in, so - // scoping on it makes "this suppression cannot reach another workspace's tab" structural rather - // than a property of where the call sites happen to sit. - // Why a live local tab still overrides its own tombstone: an id that is live here means the - // tombstone is stale (a close undone before it persisted), not a revival. Deleting a live pane is - // the one outcome this whole function exists to avoid. + // The other half of the trade below. Absence still cannot say "closed", so main's close record + // says it instead. Read only here: main alone writes records, and nothing acknowledges them away. + // Scoped to the record's own worktree, so suppression cannot reach another workspace's tab. + // Why a live local tab still overrides its record: an id that is live here means the record is + // stale, not a revival. Deleting a live pane is the one outcome this function exists to avoid. const isSuppressedByClose = (tabId: string, worktreeId: string): boolean => - Object.hasOwn(closedTerminalTabTombstonesByTabId, tabId) && - closedTerminalTabTombstonesByTabId[tabId]?.worktreeId === worktreeId && - !currentTabsById.has(tabId) + hasClosedTerminalTabRecord(closedTabRecords, tabId, worktreeId) && !currentTabsById.has(tabId) // Ids this merge actually suppressed, recorded as it walks the worktrees. The layout and // session-id sweeps below have no worktree in scope, so they consult decisions already made // rather than re-deriving one without the scope that makes it safe. @@ -271,10 +251,9 @@ export function mergeDirectSshRemoteWorkspaceSession( // the workspace or its path did not resolve to a local id, and taking that literally drops the // user onto the home screen while their terminals keep running. So it is only overridden when the // workspace they are standing in demonstrably still exists in the merged result. - // Why presence and not length, the same reading `hasLocalTabsRow` above already gives: an - // explicit empty row is the record that the user closed the last terminal, and a workspace they - // emptied still exists in the merged result. Counting rows sent them to the home screen for - // having closed their last tab. + // Why presence and not length, the same reading `hasLocalTabsRow` above already gives: a + // workspace the user emptied keeps an explicit empty row, so it still exists in the merged + // result. Counting rows sent them to the home screen for having closed their last tab. const localActiveWorkspaceSurvives = current.activeWorktreeId != null && replaceWorktreeIds.has(current.activeWorktreeId) && @@ -348,11 +327,7 @@ export function mergeDirectSshRemoteWorkspaceSession( // tabs. Removal is the worktree-teardown path's job, not a reconnect's. ...current.defaultTerminalTabsAppliedByWorktreeId, ...remote.defaultTerminalTabsAppliedByWorktreeId - }, - closedTerminalTabTombstonesByTabId: - Object.keys(closedTerminalTabTombstonesByTabId).length > 0 - ? closedTerminalTabTombstonesByTabId - : undefined + } } } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts index 00b2e8b0c26..020d7d43c92 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply-deferred-session-write.test.ts @@ -213,7 +213,6 @@ describe('session writes deferred by a direct-SSH apply', () => { expect(persist, 'the close made during the apply never reached disk').toHaveBeenCalledTimes(1) const patch = persist.mock.calls[0][0].patch - expect(patch.closedTerminalTabTombstonesByTabId?.['tab-b']?.worktreeId).toBe(WORKTREE_B) expect(patch.tabsByWorktree?.[WORKTREE_B]).toEqual([]) expect(patch.tabsByWorktree?.[WORKTREE_A]).toHaveLength(1) } finally { @@ -321,9 +320,7 @@ describe('session writes deferred by a direct-SSH apply', () => { vi.advanceTimersByTime(5_200) expect(persist, 'a clock step back stranded the deferred write').toHaveBeenCalledTimes(1) - expect( - persist.mock.calls[0][0].patch.closedTerminalTabTombstonesByTabId?.['tab-b'] - ).toBeDefined() + expect(persist.mock.calls[0][0].patch.tabsByWorktree?.[WORKTREE_B]).toEqual([]) } finally { cleanup() } diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index e304ff17597..5ca493251ec 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -182,7 +182,7 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({ state.tabsByWorktree, currentRecoveryTabIds(state, authority, worktreeIds), toSshExecutionHostId(authority.targetId), - snapshot.revision, + state.closedTerminalTabTombstonesByTabId, new Set( [...worktreeIds].flatMap((id) => (state.tabsByWorktree[id] ?? []) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 86d143fa2a6..57703fb7d1f 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2464,6 +2464,10 @@ "MobileDriverOverlay": { "3eed73394f": "Your keyboard is paused" }, + "TerminalErrorToast": { + "ptyAllocationLimit": "Your system cannot allocate any more pty devices. Close some terminals you are not using, in Orca or another program, then try again.", + "terminalProcessLimit": "Your system cannot start another terminal process. Close unused terminals or quit unused processes, then try again." + }, "TerminalRemoteRuntimeReconnectBanner": { "retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns." }, @@ -2672,6 +2676,7 @@ "title": "Start a chat with {{value0}}" }, "error": { + "retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it.", "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working.", "title": "Could not load conversation" }, @@ -2682,7 +2687,8 @@ "notAgent": { "subtitle": "This terminal is not running a recognized coding agent.", "title": "No conversation here" - } + }, + "reconnecting": "Reconnecting to this chat…" }, "status": { "responding": "Agent is responding", @@ -2710,22 +2716,54 @@ "usedOneSummary": "Used 1 tool" }, "writeNotice": { + "agentRefused": "The agent turned this down.", + "agentStarting": "The agent is still starting.", + "answerFirst": "Answer the question or approval first.", + "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", + "chatNotFound": "The Orca running this chat has no record of it.", + "clearUnfinished": "The last /clear didn't finish.", + "commandRunning": "A /compact or /clear is still running.", + "conversationCleared": "This conversation has been cleared.", + "goalsUnsupported": "This agent doesn't support goals.", + "historyUnavailable": "Orca couldn't open this chat's history right now.", "historyUnreadable": "Orca couldn't read this chat's saved history.", + "historyUnusable": "Unable to load this chat.", + "hostReconciling": "Orca is still checking on this chat after restarting.", + "messagesUnsettled": "A message you sent earlier hasn't been confirmed yet.", "notDoneAnswer": "Your answer was not sent.", "notDoneCommand": "The command didn't run.", "notDoneGoal": "The goal wasn't changed.", "notDoneOption": "The setting wasn't changed.", + "notDoneReadHistory": "This chat's history couldn't be loaded.", "notDoneSend": "Your message was not sent.", "notDoneStop": "The agent wasn't stopped.", "notDoneStopTask": "The background task wasn't stopped.", "notDoneStopTasks": "The background tasks weren't stopped.", + "openCurrentConversation": "Open the current conversation to continue.", + "optionRejected": "The agent didn't accept this setting.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", + "ownerUnproven": "Orca hasn't confirmed that this chat's previous agent stopped.", + "promptPending": "The agent is waiting for an answer to a question or approval.", "questionChanged": "This question was already answered or has changed.", + "quitTerminalAgent": "Quit that agent to continue the chat here.", + "recordFailed": "Orca couldn't record it in this chat's history.", + "recordUnreadable": "Orca couldn't read this chat's saved state.", + "reopenChat": "Reopen the chat to check again.", "restartFailed": "The agent couldn't restart.", + "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", + "startNewChat": "Start a new chat to continue.", + "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", + "tryAgain": "Try again.", "tryAgainComposerSend": "Send it again.", + "turnActive": "The agent is still responding.", "unreachable": "Orca couldn't reach the agent.", - "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again." + "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", + "waitForBackgroundTasks": "Wait for the background tasks to finish.", + "waitForCommand": "Wait for the /compact or /clear to finish.", + "waitForStart": "Wait for the agent to finish starting.", + "waitForTurn": "Wait for the agent to finish responding, or stop it.", + "waitMoment": "Wait a moment." } }, "onboarding": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 88f92cda8bd..ffdcd6031c2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -151,7 +151,8 @@ "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI.", - "cursorToggleDescription": "Show Cursor plan usage when signed in through cursor-agent or Cursor IDE." + "cursorToggleDescription": "Show Cursor plan usage when signed in through cursor-agent or Cursor IDE.", + "zcodeToggleDescription": "Show ZCode Coding Plan quota usage." }, "menuBarIcon": { "title": "Show Menu Bar Icon", @@ -585,6 +586,7 @@ "9e2a9bb87b": "Continue", "6f8056a565": "Command Code", "4238b771b5": "Codebuff", + "freebuff_label": "Freebuff", "cbaf0c2e0b": "Cline", "1f8a19e9ad": "Autohand Code", "5e8eff11b3": "Auggie", @@ -609,6 +611,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "muse_label": "Muse", + "dsh_label": "DeepSeek Harness", + "qoder_label": "Qoder CLI", "zcode_label": "ZCode", "d443a47995": "Prime Agent", "mimo_code_label": "MiMo Code", @@ -3189,6 +3193,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "Clear Screen", + "resetTerminal": "Reset Terminal", "8c17d6786d": "Close Pane", "copyTerminalId": "Copy Terminal ID", "2cf85a6a55": "Copy Pane ID", @@ -3224,7 +3229,9 @@ "e16012e31e": "The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.", "sessionUnavailable": "Orca couldn't reattach to this pane's terminal session on the host. Open a new terminal to continue.", "sourceRestoring": "Reconnecting this terminal — its output is being re-established. The session is still running.", - "remoteTerminalClosed": "Remote terminal was closed." + "remoteTerminalClosed": "Remote terminal was closed.", + "ptyAllocationLimit": "Your system cannot allocate any more pty devices. Close some terminals you are not using, in Orca or another program, then try again.", + "terminalProcessLimit": "Your system cannot start another terminal process. Close unused terminals or quit unused processes, then try again." }, "TerminalProcessExitOverlay": { "capacityTitle": "Git Bash console limit reached", @@ -3820,7 +3827,9 @@ "floatingTerminalNewActivity": "{{label}}, new activity", "codexSignInSuccess": "Signed in to Codex", "codexSignInError": "Codex sign-in failed. Please try again.", - "cursorUsageMenu": "Cursor Usage" + "cursorUsageMenu": "Cursor Usage", + "hiddenUsageProviders": "Also: {{value0}}", + "zcodeUsageMenu": "ZCode Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -3967,6 +3976,7 @@ } }, "tooltip": { + "zcode": { "mcp": "MCP" }, "cedb7b99e3": "% used", "6d6df77f41": "No data available", "7f7f208060": "Monthly", @@ -9357,6 +9367,11 @@ "c5b9f8d1e3": "xai", "usagePercentageDisplayTitle": "Usage percentages", "usagePercentageDisplayDescription": "Choose whether provider limits show the percentage used or remaining.", + "zcodeUsageTitle": "ZCode Usage", + "zcodeUsageDescription": "Show ZCode Coding Plan quota usage in the status bar.", + "zcode": "zcode", + "zai": "zai", + "glm": "glm", "tray": { "tray": "tray", "system": "system tray", @@ -17466,6 +17481,7 @@ }, "native-chat": { "writeNotice": { + "notDoneReadHistory": "This chat's history couldn't be loaded.", "notDoneSend": "Your message was not sent.", "tryAgainComposerSend": "Send it again.", "notDoneStop": "The agent wasn't stopped.", @@ -17480,8 +17496,39 @@ "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", "questionChanged": "This question was already answered or has changed.", "historyUnreadable": "Orca couldn't read this chat's saved history.", + "historyUnusable": "Unable to load this chat.", + "historyUnavailable": "Orca couldn't open this chat's history right now.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", - "unreachable": "Orca couldn't reach the agent." + "unreachable": "Orca couldn't reach the agent.", + "recordFailed": "Orca couldn't record it in this chat's history.", + "conversationCleared": "This conversation has been cleared.", + "openCurrentConversation": "Open the current conversation to continue.", + "clearUnfinished": "The last /clear didn't finish.", + "commandRunning": "A /compact or /clear is still running.", + "waitForCommand": "Wait for the /compact or /clear to finish.", + "agentStarting": "The agent is still starting.", + "waitForStart": "Wait for the agent to finish starting.", + "turnActive": "The agent is still responding.", + "waitForTurn": "Wait for the agent to finish responding, or stop it.", + "promptPending": "The agent is waiting for an answer to a question or approval.", + "answerFirst": "Answer the question or approval first.", + "backgroundTasksRunning": "Background tasks are still running.", + "waitForBackgroundTasks": "Wait for the background tasks to finish.", + "messagesUnsettled": "A message you sent earlier hasn't been confirmed yet.", + "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", + "optionRejected": "The agent didn't accept this setting.", + "goalsUnsupported": "This agent doesn't support goals.", + "agentRefused": "The agent turned this down.", + "ownerUnproven": "Orca hasn't confirmed that this chat's previous agent stopped.", + "reopenChat": "Reopen the chat to check again.", + "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", + "quitTerminalAgent": "Quit that agent to continue the chat here.", + "hostReconciling": "Orca is still checking on this chat after restarting.", + "waitMoment": "Wait a moment.", + "recordUnreadable": "Orca couldn't read this chat's saved state.", + "chatNotFound": "The Orca running this chat has no record of it.", + "startNewChat": "Start a new chat to continue.", + "tryAgain": "Try again." }, "taskList": { "title": "Tasks", @@ -17716,8 +17763,10 @@ }, "error": { "title": "Could not load conversation", - "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working." + "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working.", + "retryingSubtitle": "The transcript could not be read. Orca keeps trying to load it." }, + "reconnecting": "Reconnecting to this chat…", "pairHost": "Pair a host to view agent chat history.", "notAgent": { "title": "No conversation here", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ea139754da4..adbe527574a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2751,6 +2751,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "Borrar pantalla", + "resetTerminal": "Restablecer terminal", "8c17d6786d": "Cerrar panel", "copyTerminalId": "Copiar ID de terminal", "2cf85a6a55": "Copiar ID del panel", @@ -2777,7 +2778,9 @@ "a7e2fd2699": "abre un issue", "5c8ce20be6": "Si esto persiste, por favor", "cc6d997c65": "Reinicia el servicio del terminal desde aquí para borrar el estado obsoleto.", - "remoteTerminalClosed": "La terminal remota se cerró." + "remoteTerminalClosed": "La terminal remota se cerró.", + "ptyAllocationLimit": "Tu sistema no puede asignar más dispositivos pty. Cierra algunas terminales que no estés usando, en Orca o en otro programa, y vuelve a intentarlo.", + "terminalProcessLimit": "Tu sistema no puede iniciar otro proceso de terminal. Cierra las terminales que no uses o sal de los procesos que no necesites y vuelve a intentarlo." }, "TerminalProcessExitOverlay": { "capacityTitle": "Se alcanzó el límite de consolas de Git Bash", @@ -3345,7 +3348,8 @@ "floatingTerminalNewActivity": "{{label}}, nueva actividad", "codexSignInSuccess": "Sesión iniciada en Codex", "codexSignInError": "No se pudo iniciar sesión en Codex. Inténtalo de nuevo.", - "cursorUsageMenu": "Uso de Cursor" + "cursorUsageMenu": "Uso de Cursor", + "hiddenUsageProviders": "También: {{value0}}" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -14926,6 +14930,7 @@ "components": { "native-chat": { "writeNotice": { + "notDoneReadHistory": "No se pudo cargar el historial de este chat.", "notDoneSend": "Su mensaje no se envió.", "tryAgainComposerSend": "Vuelva a enviarlo.", "notDoneStop": "El agente no se detuvo.", @@ -14940,8 +14945,39 @@ "outcomeUnknown": "Orca no pudo confirmar qué pasó. Revise el chat.", "questionChanged": "Esta pregunta ya se respondió o ha cambiado.", "historyUnreadable": "Orca no pudo leer el historial guardado de este chat.", + "historyUnusable": "No se puede cargar este chat.", + "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", - "unreachable": "Orca no pudo comunicarse con el agente." + "unreachable": "Orca no pudo comunicarse con el agente.", + "recordFailed": "Orca no pudo registrarlo en el historial de este chat.", + "conversationCleared": "Esta conversación se borró.", + "openCurrentConversation": "Abra la conversación actual para continuar.", + "clearUnfinished": "El último /clear no terminó.", + "commandRunning": "Todavía se está ejecutando un /compact o /clear.", + "waitForCommand": "Espere a que termine el /compact o /clear.", + "agentStarting": "El agente todavía se está iniciando.", + "waitForStart": "Espere a que el agente termine de iniciarse.", + "turnActive": "El agente todavía está respondiendo.", + "waitForTurn": "Espere a que el agente termine de responder o deténgalo.", + "promptPending": "El agente está esperando una respuesta a una pregunta o aprobación.", + "answerFirst": "Responda primero a la pregunta o aprobación.", + "backgroundTasksRunning": "Todavía hay tareas en segundo plano en ejecución.", + "waitForBackgroundTasks": "Espere a que terminen las tareas en segundo plano.", + "messagesUnsettled": "Un mensaje que envió antes todavía no se ha confirmado.", + "settleEarlierMessage": "Espere a que se entregue su mensaje anterior o vuelva a intentarlo.", + "optionRejected": "El agente no aceptó este ajuste.", + "goalsUnsupported": "Este agente no admite objetivos.", + "agentRefused": "El agente lo rechazó.", + "ownerUnproven": "Orca no ha confirmado que el agente anterior de este chat se haya detenido.", + "reopenChat": "Vuelva a abrir el chat para comprobarlo de nuevo.", + "terminalAgentHoldsChat": "Este chat sigue abierto en un agente de terminal.", + "quitTerminalAgent": "Cierre ese agente para continuar el chat aquí.", + "hostReconciling": "Orca todavía está comprobando este chat después de reiniciarse.", + "waitMoment": "Espere un momento.", + "recordUnreadable": "Orca no pudo leer el estado guardado de este chat.", + "chatNotFound": "La versión de Orca que ejecuta este chat no tiene ningún registro de él.", + "startNewChat": "Inicie un chat nuevo para continuar.", + "tryAgain": "Vuelva a intentarlo." }, "composer": { "imageUnsupported": "Este agente no admite pegar imágenes.", @@ -15020,8 +15056,10 @@ }, "error": { "title": "No se pudo cargar la conversación", - "subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando." + "subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando.", + "retryingSubtitle": "No se pudo leer la transcripción. Orca sigue intentando cargarla." }, + "reconnecting": "Reconectando con este chat…", "pairHost": "Empareje un host para ver el historial de chat del agente.", "notAgent": { "title": "No hay conversación aquí", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 440d3d335b1..047564c4e61 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3136,6 +3136,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "Effacer l'écran", + "resetTerminal": "Réinitialiser le terminal", "8c17d6786d": "Fermer le volet", "copyTerminalId": "Copier l'ID du terminal", "2cf85a6a55": "Copier l'ID du volet", @@ -3171,7 +3172,9 @@ "ownerUnknown": "Orca n'a pas pu vérifier le propriétaire de ce terminal.", "42b283ecfc": "Orca n'a pas pu reconnecter ce terminal en toute sécurité car l'hôte n'a pas pu vérifier sa session enregistrée. Orca a laissé la session enregistrée inchangée. Cliquez sur Réessayer pour essayer de vous reconnecter maintenant. S'il ne parvient toujours pas à se reconnecter, ouvrez un nouveau terminal.", "sourceRestoring": "Reconnexion de ce terminal - sa sortie est en cours de rétablissement. La session est toujours en cours.", - "remoteTerminalClosed": "Le terminal distant était fermé." + "remoteTerminalClosed": "Le terminal distant était fermé.", + "ptyAllocationLimit": "Votre système ne peut plus allouer de périphériques pty. Fermez quelques terminaux inutilisés, dans Orca ou dans un autre programme, puis réessayez.", + "terminalProcessLimit": "Votre système ne peut pas démarrer un autre processus de terminal. Fermez les terminaux inutilisés ou quittez les processus inutiles, puis réessayez." }, "TerminalProcessExitOverlay": { "capacityTitle": "Limite de consoles Git Bash atteinte", @@ -3767,7 +3770,8 @@ "floatingTerminalNewActivity": "{{label}}, nouvelle activité", "codexSignInSuccess": "Connecté à Codex", "codexSignInError": "Échec de la connexion à Codex. Veuillez réessayer.", - "cursorUsageMenu": "Utilisation Cursor" + "cursorUsageMenu": "Utilisation Cursor", + "hiddenUsageProviders": "Aussi : {{value0}}" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connecter un compte", @@ -17409,6 +17413,7 @@ "components": { "native-chat": { "writeNotice": { + "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", "notDoneSend": "Votre message n'a pas été envoyé.", "tryAgainComposerSend": "Renvoyez-le.", "notDoneStop": "L'agent n'a pas été arrêté.", @@ -17423,8 +17428,39 @@ "outcomeUnknown": "Orca n'a pas pu confirmer ce qui s'est passé. Vérifiez le chat.", "questionChanged": "Cette question a déjà reçu une réponse ou a changé.", "historyUnreadable": "Orca n'a pas pu lire l'historique enregistré de ce chat.", + "historyUnusable": "Impossible de charger ce chat.", + "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", - "unreachable": "Orca n'a pas pu joindre l'agent." + "unreachable": "Orca n'a pas pu joindre l'agent.", + "recordFailed": "Orca n'a pas pu l'enregistrer dans l'historique de ce chat.", + "conversationCleared": "Cette conversation a été effacée.", + "openCurrentConversation": "Ouvrez la conversation actuelle pour continuer.", + "clearUnfinished": "Le dernier /clear ne s'est pas terminé.", + "commandRunning": "Un /compact ou /clear est encore en cours.", + "waitForCommand": "Attendez la fin du /compact ou /clear.", + "agentStarting": "L'agent est encore en cours de démarrage.", + "waitForStart": "Attendez que l'agent ait fini de démarrer.", + "turnActive": "L'agent est encore en train de répondre.", + "waitForTurn": "Attendez que l'agent ait fini de répondre, ou arrêtez-le.", + "promptPending": "L'agent attend une réponse à une question ou à une demande d'approbation.", + "answerFirst": "Répondez d'abord à la question ou à la demande d'approbation.", + "backgroundTasksRunning": "Des tâches en arrière-plan sont encore en cours.", + "waitForBackgroundTasks": "Attendez la fin des tâches en arrière-plan.", + "messagesUnsettled": "Un message que vous avez envoyé plus tôt n'a pas encore été confirmé.", + "settleEarlierMessage": "Attendez que votre message précédent soit transmis, ou renvoyez-le.", + "optionRejected": "L'agent n'a pas accepté ce réglage.", + "goalsUnsupported": "Cet agent ne prend pas en charge les objectifs.", + "agentRefused": "L'agent l'a refusé.", + "ownerUnproven": "Orca n'a pas confirmé l'arrêt de l'agent précédent de ce chat.", + "reopenChat": "Rouvrez le chat pour vérifier à nouveau.", + "terminalAgentHoldsChat": "Ce chat est encore ouvert dans un agent de terminal.", + "quitTerminalAgent": "Quittez cet agent pour continuer le chat ici.", + "hostReconciling": "Orca vérifie encore ce chat après son redémarrage.", + "waitMoment": "Patientez un instant.", + "recordUnreadable": "Orca n'a pas pu lire l'état enregistré de ce chat.", + "chatNotFound": "L'instance d'Orca qui exécute ce chat n'en a aucune trace.", + "startNewChat": "Démarrez un nouveau chat pour continuer.", + "tryAgain": "Réessayez." }, "composer": { "imageUnsupported": "Le collage d'image n'est pas pris en charge pour cet agent.", @@ -17582,8 +17618,10 @@ }, "error": { "title": "Impossible de charger la conversation", - "subtitle": "La transcription n'a pas pu être lue. Rebasculez sur le terminal pour continuer à travailler." + "subtitle": "La transcription n'a pas pu être lue. Rebasculez sur le terminal pour continuer à travailler.", + "retryingSubtitle": "La transcription n'a pas pu être lue. Orca continue d'essayer de la charger." }, + "reconnecting": "Reconnexion à ce chat…", "pairHost": "Associez un hôte pour consulter l'historique de chat des agents.", "notAgent": { "title": "Aucune conversation ici", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c6d141e1007..97ff2a6ffda 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3033,6 +3033,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "クリアスクリーン", + "resetTerminal": "ターミナルをリセット", "8c17d6786d": "ペインを閉じる", "copyTerminalId": "ターミナル ID をコピー", "2cf85a6a55": "ペインIDのコピー", @@ -3068,7 +3069,9 @@ "42b283ecfc": "ホストが保存されたセッションを確認できなかったため、Orca はこのターミナルを安全に再接続できませんでした。 Orca は保存されたセッションを変更せずに残しました。今すぐ再接続を試みるには、「再試行」をクリックします。それでも再接続できない場合は、新規ターミナルを開きます。", "e16012e31e": "このセッションを所有していたターミナルデーモンが終了したため、セッションとそのスクロールバックを回復できませんでした。続行するには、新規ターミナルを開いてください。", "sessionUnavailable": "Orca はホスト上のこのペインのターミナルセッションに再接続できませんでした。続行するには、新規ターミナルを開いてください。", - "sourceRestoring": "このターミナルを再接続しています — その出力が再確立されています。セッションはまだ実行中です。" + "sourceRestoring": "このターミナルを再接続しています — その出力が再確立されています。セッションはまだ実行中です。", + "ptyAllocationLimit": "システムはこれ以上 pty デバイスを割り当てられません。Orca または他のプログラムで使っていないターミナルをいくつか閉じてから、もう一度お試しください。", + "terminalProcessLimit": "システムはこれ以上ターミナルプロセスを起動できません。使っていないターミナルを閉じるか、不要なプロセスを終了してから、もう一度お試しください。" }, "TerminalProcessExitOverlay": { "capacityTitle": "Git Bash のコンソール上限に達しました", @@ -3672,7 +3675,8 @@ "floatingTerminalNewActivity": "{{label}}、新規アクティビティ", "codexSignInSuccess": "Codex にサインインしました", "codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。", - "cursorUsageMenu": "Cursor の使用状況" + "cursorUsageMenu": "Cursor の使用状況", + "hiddenUsageProviders": "ほかに: {{value0}}" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -17345,6 +17349,7 @@ }, "native-chat": { "writeNotice": { + "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", "notDoneSend": "メッセージは送信されませんでした。", "tryAgainComposerSend": "もう一度送信してください。", "notDoneStop": "Agent は停止されませんでした。", @@ -17359,8 +17364,39 @@ "outcomeUnknown": "Orca は何が起きたかを確認できませんでした。チャットを確認してください。", "questionChanged": "この質問はすでに回答済みか、変更されています。", "historyUnreadable": "Orca はこのチャットの保存済み履歴を読み取れませんでした。", + "historyUnusable": "このチャットを読み込めません。", + "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", - "unreachable": "Orca は Agent に接続できませんでした。" + "unreachable": "Orca は Agent に接続できませんでした。", + "recordFailed": "Orca はこのチャットの履歴に記録できませんでした。", + "conversationCleared": "この会話はクリアされました。", + "openCurrentConversation": "続けるには、現在の会話を開いてください。", + "clearUnfinished": "前回の /clear は完了しませんでした。", + "commandRunning": "/compact または /clear がまだ実行中です。", + "waitForCommand": "/compact または /clear が終わるまでお待ちください。", + "agentStarting": "Agent はまだ起動中です。", + "waitForStart": "Agent の起動が終わるまでお待ちください。", + "turnActive": "Agent はまだ応答中です。", + "waitForTurn": "Agent の応答が終わるまで待つか、Agent を停止してください。", + "promptPending": "Agent は質問または承認への回答を待っています。", + "answerFirst": "先に質問または承認に回答してください。", + "backgroundTasksRunning": "バックグラウンドタスクがまだ実行中です。", + "waitForBackgroundTasks": "バックグラウンドタスクが終わるまでお待ちください。", + "messagesUnsettled": "以前に送信したメッセージがまだ確認されていません。", + "settleEarlierMessage": "以前のメッセージが届くまで待つか、再試行してください。", + "optionRejected": "Agent はこの設定を受け入れませんでした。", + "goalsUnsupported": "この Agent は目標に対応していません。", + "agentRefused": "Agent がこれを拒否しました。", + "ownerUnproven": "Orca は、このチャットの以前の Agent が停止したことをまだ確認できていません。", + "reopenChat": "もう一度確認するには、チャットを開き直してください。", + "terminalAgentHoldsChat": "このチャットはまだターミナルの Agent で開かれています。", + "quitTerminalAgent": "ここでチャットを続けるには、その Agent を終了してください。", + "hostReconciling": "Orca は再起動後、このチャットをまだ確認しています。", + "waitMoment": "しばらくお待ちください。", + "recordUnreadable": "Orca はこのチャットの保存済みの状態を読み取れませんでした。", + "chatNotFound": "このチャットを実行している Orca には、このチャットの記録がありません。", + "startNewChat": "続けるには、新しいチャットを開始してください。", + "tryAgain": "もう一度お試しください。" }, "composer": { "imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。", @@ -17518,8 +17554,10 @@ }, "error": { "title": "会話を読み込めませんでした", - "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、ターミナルに切り替えてください。" + "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、ターミナルに切り替えてください。", + "retryingSubtitle": "トランスクリプトを読み取ることができませんでした。Orca は読み込みを再試行し続けます。" }, + "reconnecting": "このチャットに再接続しています…", "pairHost": "ホストをペアリングして、Agent のチャット履歴を表示します。", "notAgent": { "title": "ここでは会話はありません", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index da6bc689445..511ee010e70 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3033,6 +3033,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "화면 지우기", + "resetTerminal": "터미널 초기화", "8c17d6786d": "창 닫기", "copyTerminalId": "터미널 ID 복사", "2cf85a6a55": "창 ID 복사", @@ -3068,7 +3069,9 @@ "42b283ecfc": "호스트가 저장된 세션을 확인할 수 없기 때문에 Orca가 이 터미널을 안전하게 다시 연결할 수 없습니다. 오르카는 저장된 세션을 변경하지 않고 그대로 두었습니다. 지금 다시 연결을 시도하려면 재시도를 클릭하세요. 그래도 다시 연결할 수 없으면 새 터미널을 엽니다.", "e16012e31e": "이 세션을 소유한 터미널 데몬이 종료되었으므로 세션과 해당 스크롤백을 복구할 수 없습니다. 계속하려면 새 터미널을 여세요.", "sessionUnavailable": "Orca가 호스트에서 이 창의 터미널 세션에 다시 연결할 수 없습니다. 계속하려면 새 터미널을 여세요.", - "sourceRestoring": "이 터미널을 다시 연결하는 중 - 출력이 다시 설정되고 있습니다. 세션이 아직 실행 중입니다." + "sourceRestoring": "이 터미널을 다시 연결하는 중 - 출력이 다시 설정되고 있습니다. 세션이 아직 실행 중입니다.", + "ptyAllocationLimit": "시스템에서 더 이상 pty 장치를 할당할 수 없습니다. Orca 또는 다른 프로그램에서 사용하지 않는 터미널을 몇 개 닫은 후 다시 시도하세요.", + "terminalProcessLimit": "시스템에서 터미널 프로세스를 더 시작할 수 없습니다. 사용하지 않는 터미널을 닫거나 불필요한 프로세스를 종료한 후 다시 시도하세요." }, "TerminalProcessExitOverlay": { "capacityTitle": "Git Bash 콘솔 한도에 도달했습니다", @@ -3672,7 +3675,8 @@ "floatingTerminalNewActivity": "{{label}}, 새 활동", "codexSignInSuccess": "Codex에 로그인했습니다", "codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요.", - "cursorUsageMenu": "Cursor 사용량" + "cursorUsageMenu": "Cursor 사용량", + "hiddenUsageProviders": "그 외: {{value0}}" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -17345,6 +17349,7 @@ }, "native-chat": { "writeNotice": { + "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", "notDoneSend": "메시지가 전송되지 않았습니다.", "tryAgainComposerSend": "다시 보내세요.", "notDoneStop": "에이전트가 중지되지 않았습니다.", @@ -17359,8 +17364,39 @@ "outcomeUnknown": "Orca가 무슨 일이 있었는지 확인하지 못했습니다. 채팅을 확인하세요.", "questionChanged": "이 질문은 이미 답변되었거나 변경되었습니다.", "historyUnreadable": "Orca가 이 채팅의 저장된 기록을 읽지 못했습니다.", + "historyUnusable": "이 채팅을 불러올 수 없습니다.", + "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", - "unreachable": "Orca가 에이전트에 연결하지 못했습니다." + "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", + "recordFailed": "Orca가 이 채팅의 기록에 저장하지 못했습니다.", + "conversationCleared": "이 대화는 지워졌습니다.", + "openCurrentConversation": "계속하려면 현재 대화를 여세요.", + "clearUnfinished": "마지막 /clear가 완료되지 않았습니다.", + "commandRunning": "/compact 또는 /clear가 아직 실행 중입니다.", + "waitForCommand": "/compact 또는 /clear가 끝날 때까지 기다리세요.", + "agentStarting": "에이전트가 아직 시작 중입니다.", + "waitForStart": "에이전트가 시작을 마칠 때까지 기다리세요.", + "turnActive": "에이전트가 아직 응답 중입니다.", + "waitForTurn": "에이전트가 응답을 마칠 때까지 기다리거나 에이전트를 중지하세요.", + "promptPending": "에이전트가 질문 또는 승인에 대한 응답을 기다리고 있습니다.", + "answerFirst": "먼저 질문 또는 승인에 응답하세요.", + "backgroundTasksRunning": "백그라운드 작업이 아직 실행 중입니다.", + "waitForBackgroundTasks": "백그라운드 작업이 끝날 때까지 기다리세요.", + "messagesUnsettled": "앞서 보낸 메시지가 아직 확인되지 않았습니다.", + "settleEarlierMessage": "앞서 보낸 메시지가 전달될 때까지 기다리거나 다시 시도하세요.", + "optionRejected": "에이전트가 이 설정을 받아들이지 않았습니다.", + "goalsUnsupported": "이 에이전트는 목표를 지원하지 않습니다.", + "agentRefused": "에이전트가 이를 거부했습니다.", + "ownerUnproven": "Orca가 이 채팅의 이전 에이전트가 중지되었는지 아직 확인하지 못했습니다.", + "reopenChat": "다시 확인하려면 채팅을 다시 여세요.", + "terminalAgentHoldsChat": "이 채팅은 아직 터미널 에이전트에서 열려 있습니다.", + "quitTerminalAgent": "여기에서 채팅을 계속하려면 해당 에이전트를 종료하세요.", + "hostReconciling": "Orca가 다시 시작된 후 이 채팅을 아직 확인하고 있습니다.", + "waitMoment": "잠시 기다리세요.", + "recordUnreadable": "Orca가 이 채팅의 저장된 상태를 읽지 못했습니다.", + "chatNotFound": "이 채팅을 실행하는 Orca에 이 채팅의 기록이 없습니다.", + "startNewChat": "계속하려면 새 채팅을 시작하세요.", + "tryAgain": "다시 시도하세요." }, "composer": { "imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.", @@ -17518,8 +17554,10 @@ }, "error": { "title": "대화를 로드할 수 없습니다.", - "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요." + "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요.", + "retryingSubtitle": "스크립트를 읽을 수 없습니다. Orca가 계속 다시 불러오기를 시도합니다." }, + "reconnecting": "이 채팅에 다시 연결하는 중…", "pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.", "notAgent": { "title": "여기에는 대화가 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 92edb440c65..e4fa1521899 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3033,6 +3033,7 @@ }, "TerminalContextMenu": { "b4cdd9314e": "清屏", + "resetTerminal": "重置终端", "8c17d6786d": "关闭窗格", "copyTerminalId": "复制终端 ID", "2cf85a6a55": "复制窗格 ID", @@ -3068,7 +3069,9 @@ "42b283ecfc": "Orca 无法安全地重新连接此终端,因为主机无法验证其保存的会话。 Orca 保持保存的会话不变。单击“重试”立即尝试重新连接。如果仍然无法重新连接,请打开一个新终端。", "e16012e31e": "拥有该会话的终端守护程序已退出,因此该会话及其回滚无法恢复。打开新终端以继续。", "sessionUnavailable": "Orca 无法重新连接到主机上此窗格的终端会话。打开新终端以继续。", - "sourceRestoring": "重新连接该终端 — 正在重新建立其输出。会话仍在进行。" + "sourceRestoring": "重新连接该终端 — 正在重新建立其输出。会话仍在进行。", + "ptyAllocationLimit": "系统无法再分配 pty 设备。请在 Orca 或其他程序中关闭一些不用的终端,然后重试。", + "terminalProcessLimit": "系统无法再启动终端进程。请关闭不用的终端或退出不用的进程,然后重试。" }, "TerminalProcessExitOverlay": { "capacityTitle": "已达到 Git Bash 控制台上限", @@ -3672,7 +3675,8 @@ "floatingTerminalNewActivity": "{{label}},有新活动", "codexSignInSuccess": "已登录 Codex", "codexSignInError": "Codex 登录失败,请重试。", - "cursorUsageMenu": "Cursor 用量" + "cursorUsageMenu": "Cursor 用量", + "hiddenUsageProviders": "另有:{{value0}}" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -17310,6 +17314,7 @@ "components": { "native-chat": { "writeNotice": { + "notDoneReadHistory": "无法加载此聊天的历史记录。", "notDoneSend": "您的消息未发送。", "tryAgainComposerSend": "请重新发送。", "notDoneStop": "智能体未停止。", @@ -17324,8 +17329,39 @@ "outcomeUnknown": "Orca 无法确认发生了什么。请查看聊天。", "questionChanged": "此问题已被回答或已更改。", "historyUnreadable": "Orca 无法读取此聊天的已保存历史记录。", + "historyUnusable": "无法加载此聊天。", + "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", - "unreachable": "Orca 无法连接到智能体。" + "unreachable": "Orca 无法连接到智能体。", + "recordFailed": "Orca 无法将其记录到此聊天的历史记录中。", + "conversationCleared": "此对话已被清除。", + "openCurrentConversation": "请打开当前对话以继续。", + "clearUnfinished": "上一次 /clear 未完成。", + "commandRunning": "/compact 或 /clear 仍在运行。", + "waitForCommand": "请等待 /compact 或 /clear 完成。", + "agentStarting": "智能体仍在启动。", + "waitForStart": "请等待智能体完成启动。", + "turnActive": "智能体仍在回复。", + "waitForTurn": "请等待智能体完成回复,或将其停止。", + "promptPending": "智能体正在等待您回答问题或审批。", + "answerFirst": "请先回答问题或审批。", + "backgroundTasksRunning": "后台任务仍在运行。", + "waitForBackgroundTasks": "请等待后台任务完成。", + "messagesUnsettled": "您之前发送的一条消息尚未确认。", + "settleEarlierMessage": "请等待之前的消息送达,或重试该消息。", + "optionRejected": "智能体未接受此设置。", + "goalsUnsupported": "此智能体不支持目标。", + "agentRefused": "智能体拒绝了此请求。", + "ownerUnproven": "Orca 尚未确认此聊天之前的智能体已停止。", + "reopenChat": "请重新打开聊天以再次检查。", + "terminalAgentHoldsChat": "此聊天仍在终端智能体中打开。", + "quitTerminalAgent": "请退出该智能体,以便在此处继续聊天。", + "hostReconciling": "Orca 重启后仍在检查此聊天。", + "waitMoment": "请稍候。", + "recordUnreadable": "Orca 无法读取此聊天的已保存状态。", + "chatNotFound": "运行此聊天的 Orca 没有此聊天的记录。", + "startNewChat": "请开始新的聊天以继续。", + "tryAgain": "请重试。" }, "composer": { "imageUnsupported": "此智能体不支持粘贴图片。", @@ -17483,8 +17519,10 @@ }, "error": { "title": "无法加载对话", - "subtitle": "无法读取记录。请切回终端继续工作。" + "subtitle": "无法读取记录。请切回终端继续工作。", + "retryingSubtitle": "无法读取记录。Orca 会继续尝试加载。" }, + "reconnecting": "正在重新连接此聊天…", "pairHost": "配对主机以查看智能体聊天记录。", "notAgent": { "title": "这里没有对话", diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 42c9baa7f6e..56cd2180c72 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -127,6 +127,20 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => faviconDomain: 'dev.meta.ai', homepageUrl: 'https://dev.meta.ai/docs/muse-code' }, + { + id: 'dsh', + label: translate('auto.lib.agent.catalog.dsh_label', 'DeepSeek Harness'), + cmd: 'dsh-tui', + searchAliases: ['deepseek', 'dsh', 'dst', 'deepseek harness'], + homepageUrl: 'https://deepseek-harness.github.io/deepseek-harness/' + }, + { + id: 'qoder', + label: translate('auto.lib.agent.catalog.qoder_label', 'Qoder CLI'), + cmd: 'qodercli', + faviconDomain: 'qoder.com', + homepageUrl: 'https://docs.qoder.com/cli/overview' + }, { id: 'zcode', label: translate('auto.lib.agent.catalog.zcode_label', 'ZCode'), @@ -242,6 +256,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => faviconDomain: 'codebuff.com', homepageUrl: 'https://www.codebuff.com/docs/help/quick-start' }, + { + id: 'freebuff', + label: translate('auto.lib.agent.catalog.freebuff_label', 'Freebuff'), + cmd: 'freebuff', + faviconDomain: 'freebuff.com', + homepageUrl: 'https://freebuff.com/cli' + }, { id: 'command-code', label: translate('auto.lib.agent.catalog.6f8056a565', 'Command Code'), diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index a76086b6790..19d5a30d275 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -12,6 +12,7 @@ import { } from '@/components/terminal-pane/terminal-bracketed-paste' import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction' import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' // Why: bracketed paste markers let supported TUIs treat generated prompt text // as one paste instead of echoing character-by-character or triggering edits. @@ -29,10 +30,11 @@ export async function sendAgentDraftPasteContent( settings: Pick | null | undefined, ptyId: string, content: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { return await runTerminalPtyInputTransaction(ptyId, () => - sendAgentDraftPasteContentNow(settings, ptyId, content, writePty) + sendAgentDraftPasteContentNow(settings, ptyId, content, inputKind, writePty) ) } @@ -42,6 +44,7 @@ export async function sendAgentDraftPasteContentNow( settings: Pick | null | undefined, ptyId: string, content: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { if (content.length > AGENT_DRAFT_PASTE_MAX_BYTES) { @@ -57,6 +60,7 @@ export async function sendAgentDraftPasteContentNow( settings, ptyId, wrapTerminalBracketedPasteText(terminalContent), + inputKind, writePty ) } @@ -71,16 +75,16 @@ export async function sendAgentDraftPasteContentNow( for (const chunk of iterateAgentDraftPasteContentChunks(terminalContent)) { let accepted = false try { - accepted = await writeAgentDraftPtyInput(settings, ptyId, chunk, writePty) + accepted = await writeAgentDraftPtyInput(settings, ptyId, chunk, inputKind, writePty) } catch { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId, writePty) + await closeAgentDraftBracketedPaste(settings, ptyId, inputKind, writePty) } return false } if (!accepted) { if (bracketedPasteOpen && chunk !== BRACKETED_PASTE_END) { - await closeAgentDraftBracketedPaste(settings, ptyId, writePty) + await closeAgentDraftBracketedPaste(settings, ptyId, inputKind, writePty) } return false } @@ -197,20 +201,24 @@ async function writeAgentDraftPtyInput( settings: Pick | null | undefined, ptyId: string, data: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { - return writePty ? await writePty(data) : await sendRuntimePtyInputVerified(settings, ptyId, data) + return writePty + ? await writePty(data) + : await sendRuntimePtyInputVerified(settings, ptyId, data, inputKind) } async function closeAgentDraftBracketedPaste( settings: Pick | null | undefined, ptyId: string, + inputKind: TerminalInputKind, writePty?: AgentDraftPtyInputWriter ): Promise { try { // Why: once the opener reached the PTY, a failed content chunk should not // leave the target TUI in bracketed-paste mode. - await writeAgentDraftPtyInput(settings, ptyId, BRACKETED_PASTE_END, writePty) + await writeAgentDraftPtyInput(settings, ptyId, BRACKETED_PASTE_END, inputKind, writePty) } catch { // The original write already failed; callers only need the paste to fail closed. } diff --git a/src/renderer/src/lib/agent-favicon-assets.ts b/src/renderer/src/lib/agent-favicon-assets.ts index 6c7221efe80..b041965736e 100644 --- a/src/renderer/src/lib/agent-favicon-assets.ts +++ b/src/renderer/src/lib/agent-favicon-assets.ts @@ -1,3 +1,4 @@ +import qoderUrl from '../../../shared/agent-icons/qoder.png?url' import type { TuiAgent } from '../../../shared/tui-agent' import grokUrl from '../../../shared/agent-icons/grok.png?url' import mimoCodeUrl from '../../../shared/agent-icons/mimo-code.png?url' @@ -13,6 +14,7 @@ import crushUrl from '../../../shared/agent-icons/crush.png?url' import augUrl from '../../../shared/agent-icons/aug.png?url' import autohandUrl from '../../../shared/agent-icons/autohand.png?url' import clineUrl from '../../../shared/agent-icons/cline.png?url' +import freebuffUrl from '../../../shared/agent-icons/freebuff.png?url' import codebuffUrl from '../../../shared/agent-icons/codebuff.png?url' import commandCodeUrl from '../../../shared/agent-icons/command-code.png?url' import continueUrl from '../../../shared/agent-icons/continue.png?url' @@ -26,6 +28,7 @@ import devinUrl from '../../../shared/agent-icons/devin.png?url' import museUrl from '../../../shared/agent-icons/muse.png?url' import zcodeUrl from '../../../shared/agent-icons/zcode.png?url' import openclawUrl from '../../../shared/agent-icons/openclaw.png?url' +import dshUrl from '../../../shared/agent-icons/dsh.png?url' // Why: these agents have no hand-authored SVG glyph, so previously their icons // loaded live from Google's favicon service. That service is unreachable in some @@ -40,6 +43,7 @@ export const AGENT_FAVICON_ASSETS: Partial> = { ante: anteUrl, trae: traeUrl, 'prime-agent': primeAgentUrl, + qoder: qoderUrl, gemini: geminiUrl, antigravity: antigravityUrl, goose: gooseUrl, @@ -50,6 +54,7 @@ export const AGENT_FAVICON_ASSETS: Partial> = { autohand: autohandUrl, cline: clineUrl, codebuff: codebuffUrl, + freebuff: freebuffUrl, 'command-code': commandCodeUrl, continue: continueUrl, cursor: cursorUrl, @@ -60,6 +65,7 @@ export const AGENT_FAVICON_ASSETS: Partial> = { hermes: hermesUrl, devin: devinUrl, muse: museUrl, + dsh: dshUrl, zcode: zcodeUrl, openclaw: openclawUrl } diff --git a/src/renderer/src/lib/agent-followup-delivery.test.ts b/src/renderer/src/lib/agent-followup-delivery.test.ts index 207c70ada36..48534cf8b06 100644 --- a/src/renderer/src/lib/agent-followup-delivery.test.ts +++ b/src/renderer/src/lib/agent-followup-delivery.test.ts @@ -49,7 +49,7 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = }) expect(delivered).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch') }) it(`still refuses to type into a bare ${agent} shell foreground`, async () => { @@ -105,6 +105,6 @@ describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () = }) expect(delivered).toBe(true) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r') + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r', 'launch') }) }) diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts index bdcf0b4a92f..e520cb6df1f 100644 --- a/src/renderer/src/lib/agent-followup-delivery.ts +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -22,7 +22,7 @@ export async function sendFollowupPromptWhenAgentReady(args: { return false } try { - return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`) + return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`, 'launch') } catch { return false } diff --git a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts index 5b763aa5c6d..84ad14770d1 100644 --- a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts @@ -105,7 +105,12 @@ describe('post-paste submit retry Enter', () => { await expect(promise).resolves.toBe(true) expect(enterWrites()).toHaveLength(2) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r') + expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith( + {}, + 'pty-1', + '\r', + 'launch' + ) expect(vi.getTimerCount()).toBe(0) }) @@ -143,7 +148,8 @@ describe('post-paste submit retry Enter', () => { const competing = sendAgentDraftPasteContent( {}, 'pty-1', - 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1) + 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1), + 'driving' ) await flushMicrotasks(10) expect(writes).toEqual([PASTED_ISSUE_URL, '\r']) diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index fdcb76cf2ed..bb07fb58504 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -57,7 +57,9 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, - sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified, + // Why drop the kind: this suite pins write shapes; startup-draft-input-kind.test.ts pins kinds. + sendRuntimePtyInputVerified: (settings: unknown, ptyId: string, data: string) => + testState.sendRuntimePtyInputVerified(settings, ptyId, data), inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess })) @@ -726,7 +728,8 @@ describe('pasteDraftWhenAgentReady', () => { const competing = sendAgentDraftPasteContent( {}, 'pty-1', - 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1) + 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1), + 'driving' ) await flushMicrotasks(10) expect(writes).toEqual([PASTED_ISSUE_URL]) @@ -902,7 +905,7 @@ describe('pasteDraftWhenAgentReady', () => { it('yields during large accepted-size preflight before writing agent draft chunks', async () => { const content = 'x'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 300 * 1024) - const promise = sendAgentDraftPasteContent({}, 'pty-1', content) + const promise = sendAgentDraftPasteContent({}, 'pty-1', content, 'driving') await flushMicrotasks(5) expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() @@ -915,9 +918,9 @@ describe('pasteDraftWhenAgentReady', () => { }) it('rejects oversized agent drafts before any PTY write', async () => { - await expect( - sendAgentDraftPasteContent({}, 'pty-1', 'x'.repeat(AGENT_DRAFT_PASTE_MAX_BYTES + 1)) - ).resolves.toBe(false) + const oversized = 'x'.repeat(AGENT_DRAFT_PASTE_MAX_BYTES + 1) + const result = sendAgentDraftPasteContent({}, 'pty-1', oversized, 'driving') + await expect(result).resolves.toBe(false) expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index a260d6bee18..feca855338a 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { TuiAgent } from '../../../shared/tui-agent' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { useAppStore } from '@/store' @@ -136,7 +137,9 @@ export async function pasteDraftWhenAgentReady(args: { ptyId, content, submit: submit === true, - agent + agent, + // Why launch: this delivers the prompt or draft an agent is started with. + inputKind: 'launch' }) } @@ -188,7 +191,9 @@ export async function pasteDraftToAgentPtyWhenReady(args: { ptyId, content, submit: submit === true, - agent + agent, + // Why launch: this delivers the prompt or draft an agent is started with. + inputKind: 'launch' }) } @@ -201,7 +206,8 @@ export async function submitPromptToAgentPty(args: { settings: getSettingsForAgentTabRuntimeOwner(args.tabId), ptyId: args.ptyId, content: args.content, - submit: true + submit: true, + inputKind: 'driving' }) } @@ -209,7 +215,7 @@ export async function sendBracketedPasteToRunningAgent(args: { ptyId: string content: string }): Promise { - return await sendBracketedPasteToAgent({ ptyId: args.ptyId, content: args.content, submit: true }) + return await sendBracketedPasteToAgent({ ...args, submit: true, inputKind: 'driving' }) } async function sendBracketedPasteToAgent(args: { @@ -218,14 +224,16 @@ async function sendBracketedPasteToAgent(args: { content: string submit: boolean agent?: TuiAgent + inputKind: TerminalInputKind }): Promise { const { settings = useAppStore.getState().settings, ptyId, content, submit, agent } = args + const { inputKind } = args const submitRetryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined try { // Why: paste + Enter (+ retry Enter) must be one transaction, or a concurrent // paste on this PTY can slip between them and submit a half-written prompt. return await runTerminalPtyInputTransaction(ptyId, async () => { - const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content) + const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content, inputKind) if (!pasted || !submit) { return pasted } @@ -234,14 +242,14 @@ async function sendBracketedPasteToAgent(args: { // Enter arrive in the same PTY write. Split the submit into the next turn so // the TUI processes bracketed-paste termination before handling Enter. await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r') + const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) if (submitRetryDelayMs !== undefined) { // Why: agents that render their composer before Enter is live silently eat // the first Enter; the retry is best-effort and never downgrades `submitted`. await new Promise((resolve) => window.setTimeout(resolve, submitRetryDelayMs)) try { - await sendRuntimePtyInputVerified(settings, ptyId, '\r') + await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) } catch { // Why: a rejected retry leaves the first Enter's verdict untouched. } diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index acf7d7cbd53..41ccc2cc7d7 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -109,6 +109,7 @@ const ICONABLE_AGENT_TYPES: Record = { pi: true, omp: true, 'prime-agent': true, + qoder: true, gemini: true, antigravity: true, aider: true, @@ -120,6 +121,7 @@ const ICONABLE_AGENT_TYPES: Record = { aug: true, cline: true, codebuff: true, + freebuff: true, 'command-code': true, continue: true, cursor: true, @@ -136,7 +138,8 @@ const ICONABLE_AGENT_TYPES: Record = { ante: true, trae: true, muse: true, - zcode: true + zcode: true, + dsh: true } // Why: return null (not a 'claude' fallback) for unknown so Codex panes don't flash the Claude icon before the hook fires. diff --git a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts index 11ade27d58a..ed62c67f356 100644 --- a/src/renderer/src/lib/launch-agent-background-session-remote.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session-remote.test.ts @@ -178,7 +178,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "claude '--dangerously-skip-permissions' 'run the automation'\r" + "claude '--dangerously-skip-permissions' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -214,7 +215,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -241,7 +243,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox'\r" + "codex '--dangerously-bypass-approvals-and-sandbox'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -262,7 +265,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox'\r" + "codex '--dangerously-bypass-approvals-and-sandbox'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -288,7 +292,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -312,7 +317,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r" + "codex '--dangerously-bypass-approvals-and-sandbox' 'run the automation'\r", + 'launch' ) } finally { vi.useRealTimers() @@ -353,7 +359,8 @@ describe('launchAgentBackgroundSession remote runtime and SSH startup delivery', expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r" + "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r", + 'launch' ) } finally { vi.useRealTimers() diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 9ef4bcfae4a..07c39dd082a 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -118,7 +118,7 @@ export async function launchAgentBackgroundSession( command: sshConnectionId ? startupPlan.launchCommand : null, waitForShellReady: Boolean(sshConnectionId) && sshBackgroundLaunchWaitsForShellReady(startupPlan), - write: (ptyId, data) => window.api.pty.write(ptyId, data) + write: (ptyId, data) => window.api.pty.write(ptyId, data, 'launch') }) // Route by the worktree's owner host, not the focused runtime. const runtimeTarget = getActiveRuntimeTarget( diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 759f5c7d614..e1594d94c97 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -12,6 +12,11 @@ import { import { resolveStructuredLaunchSeedOptions } from '../../../shared/native-chat-session-option-defaults' import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal' +import { + readAgentSessionRefusalReference, + type AgentSessionRefusalReference +} from '../../../shared/agent-session-wire-refusals' +import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { useAppStore } from '@/store' @@ -47,7 +52,9 @@ class StructuredAgentSessionCreateError extends Error { constructor( message: string, /** The wire refusal code, or the RPC error code when the create never reached a handler. */ - readonly code: string + readonly code: string, + /** The host's refusal as a reader may word it; absent from an older host or a local failure. */ + readonly refusal?: AgentSessionRefusalReference ) { super(message) } @@ -58,8 +65,12 @@ class StructuredAgentSessionCreateError extends Error { * `launchStructuredAgentSession` is the only place that decides it against the shared allowlist. */ export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSessionCreateError { - constructor(message: string, code: string = 'structured_agent_session_unsupported') { - super(message, code) + constructor( + message: string, + code: string = 'structured_agent_session_unsupported', + refusal?: AgentSessionRefusalReference + ) { + super(message, code, refusal) this.name = 'StructuredAgentSessionCreateRefusalError' } } @@ -70,8 +81,8 @@ export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSes * down the same path as a lost reply, which replays the intent and reconciles. */ export class StructuredAgentSessionCreateUnknownOutcomeError extends StructuredAgentSessionCreateError { - constructor(message: string, code: string) { - super(message, code) + constructor(message: string, code: string, refusal?: AgentSessionRefusalReference) { + super(message, code, refusal) this.name = 'StructuredAgentSessionCreateUnknownOutcomeError' } } @@ -252,7 +263,8 @@ async function hostSupportsCreate(intent: StructuredAgentSessionLaunchIntent): P } throw new StructuredAgentSessionCreateUnknownOutcomeError( error instanceof Error ? error.message : String(error), - code + code, + readAgentSessionErrorRefusal(error) ) } } @@ -291,20 +303,22 @@ export async function launchStructuredAgentSession( abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateRefusalError( error instanceof Error ? error.message : String(error), - code + code, + readAgentSessionErrorRefusal(error) ) } throw error } if (!result.ok) { const { code, message, ownerVerdict } = result.refusal + const refusal = readAgentSessionRefusalReference(result.refusal) // A failed operation whose provider is proven gone is a failure a new operation may retry. if (!isDefinitiveAgentSessionCreateRefusal(code) && ownerVerdict !== 'exited') { // Keep the focus intent: the session may exist, and recovery still has to adopt it. - throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code) + throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code, refusal) } abandonStructuredAgentSessionLaunchIntent(intent) - throw new StructuredAgentSessionCreateRefusalError(message, code) + throw new StructuredAgentSessionCreateRefusalError(message, code, refusal) } return { sessionId: result.value.sessionId, fence: result.value.fence } } diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 78d59f781f2..d3ca4fa01e6 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -293,7 +293,12 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledWith({}, 'pty-1', 'fix the spinner\r') + expect(mockSendRuntimePtyInputVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + 'fix the spinner\r', + 'launch' + ) expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) diff --git a/src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts b/src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts index df8bf5217d0..2a65442bd18 100644 --- a/src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts +++ b/src/renderer/src/lib/session-write-subscriber-deferred-persist.test.ts @@ -104,16 +104,12 @@ describe('session write subscriber defers writes across a closed persistence gat expect(persist).toHaveBeenCalledTimes(1) const patch = persist.mock.calls[0][0].patch + // Close records are main's; the renderer's mirror of them is never written back. + expect(patch.closedTerminalTabTombstonesByTabId).toBeUndefined() expect( - patch.closedTerminalTabTombstonesByTabId, - 'the tombstone written during the apply window was discarded, not deferred' - ).toEqual({ - 'closed-during-apply': { - closedAt: expect.any(Number), - worktreeId: 'wt-remote' - } - }) - expect(patch.activeTabId).toBe('still-open-tab') + patch.activeTabId, + 'the mutation made during the apply window was discarded, not deferred' + ).toBe('still-open-tab') expect(patch.activeRepoId).toBe('repo-1') cleanup() }) @@ -198,7 +194,7 @@ describe('session write subscriber defers writes across a closed persistence gat 'target B lost a tab close because target A was mid-apply' ).toEqual([]) expect(patch.tabsByWorktree?.['wt-target-a']).toHaveLength(1) - expect(patch.closedTerminalTabTombstonesByTabId?.['tab-b']?.worktreeId).toBe('wt-target-b') + expect(patch.closedTerminalTabTombstonesByTabId).toBeUndefined() cleanup() }) diff --git a/src/renderer/src/lib/startup-draft-input-kind.test.ts b/src/renderer/src/lib/startup-draft-input-kind.test.ts new file mode 100644 index 00000000000..34c96e3073f --- /dev/null +++ b/src/renderer/src/lib/startup-draft-input-kind.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom +// A created worktree's draft reaches its agent by one of two renderer routes: the pane's own +// startup paste, or the draft helper that waits for the agent. Both must write it as launch input. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' +import { bindSettlePaneSerializer } from '@/components/terminal-pane/pty-connection/pane-serializer-settle' +import type { ConnectPanePtySession } from '@/components/terminal-pane/pty-connection/connect-pane-pty-session' +import { STARTUP_DRAFT_PASTE_QUIET_MS } from '@/components/terminal-pane/pty-connection/pty-connect-limits' +import { pasteDraftToAgentPtyWhenReady, submitPromptToAgentPty } from './agent-paste-draft' + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ settings: {}, terminalLayoutsByTabId: {} }), + subscribe: () => () => {} + } +})) + +vi.mock('./worktree-runtime-owner', () => ({ + getSettingsForWorktreeRuntimeOwner: () => ({}), + getRuntimeEnvironmentIdForWorktree: () => null +})) + +vi.mock('./agent-draft-readiness', () => ({ + waitForAgentDraftInputReady: async () => true +})) + +const DRAFT = 'plan the migration' +const originalApi = window.api + +function stubPtyApi(): { kinds: TerminalInputKind[] } { + const kinds: TerminalInputKind[] = [] + const writeAccepted = vi.fn(async (_id: string, _data: string, inputKind: TerminalInputKind) => { + kinds.push(inputKind) + return true + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both routes reach only pty.writeAccepted. + window.api = { pty: { writeAccepted, write: vi.fn() } } as unknown as typeof window.api + return { kinds } +} + +/** The pane route: the pane's transport pastes the draft once the agent's composer is ready. */ +async function paneRouteKinds(): Promise { + const { kinds } = stubPtyApi() + const transport = { + getPtyId: () => 'pty-1', + sendInput: () => true, + sendInputAccepted: (data: string, inputKind: TerminalInputKind) => + window.api.pty.writeAccepted('pty-1', data, inputKind) + } + const pane = { id: 1 } + const bag: Record = { + pane, + transport, + disposed: false, + connectionId: null, + ownsStartupDraftPaste: true, + shouldDeliverStartupViaTerminalPaste: false, + startupDraftPrompt: DRAFT, + recordTerminalInputForHibernation: () => {}, + deps: { worktreeId: 'wt-1', paneTransportsRef: { current: new Map([[pane.id, transport]]) } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session is an any-typed bag; the draft path reads only the fields above. + const session = bag as unknown as ConnectPanePtySession + bindSettlePaneSerializer(session) + session.observeStartupDraftPasteReadiness('\x1b[?2004h') + await vi.advanceTimersByTimeAsync(STARTUP_DRAFT_PASTE_QUIET_MS) + await vi.runAllTimersAsync() + return kinds +} + +/** The helper route: the draft helper waits for the agent, then writes through the runtime. */ +async function helperRouteKinds(): Promise { + const { kinds } = stubPtyApi() + const pasted = pasteDraftToAgentPtyWhenReady({ + tabId: 'tab-1', + ptyId: 'pty-1', + content: DRAFT, + agent: 'aider' + }) + await vi.runAllTimersAsync() + await expect(pasted).resolves.toBe(true) + return kinds +} + +describe('a created worktree’s startup draft', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + window.api = originalApi + }) + + it('writes as launch input on both renderer routes', async () => { + const paneKinds = await paneRouteKinds() + const helperKinds = await helperRouteKinds() + + expect(paneKinds.length).toBeGreaterThan(0) + expect(new Set(paneKinds)).toEqual(new Set(['launch'])) + expect(new Set(helperKinds)).toEqual(new Set(paneKinds)) + }) + + it('differs from a prompt submitted to an agent that is already running', async () => { + const { kinds } = stubPtyApi() + + const submitted = submitPromptToAgentPty({ tabId: 'tab-1', ptyId: 'pty-1', content: DRAFT }) + await vi.runAllTimersAsync() + + await expect(submitted).resolves.toBe(true) + expect(new Set(kinds)).toEqual(new Set(['driving'])) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts b/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts index 1662e4c0430..10ebbc8fd89 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom // The launch client against the create wire contract: a failed create whose provider is proven -// gone reads as failed (with its reason) and Retry starts it again; any other verdict stays unknown. +// gone reads as failed (with its refusal) and Retry starts it again; any other verdict stays unknown. import { beforeEach, describe, expect, it, vi } from 'vitest' import type { @@ -49,13 +49,14 @@ import { startStructuredAgentLaunch } from './structured-agent-session-launch' import { - getStructuredAgentSessionLaunchFailureReason, + getStructuredAgentSessionLaunchFailure, resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' const WORKTREE = 'wt-1' const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' +const HOST_REFUSAL = { kind: 'refused', code: 'agent_session_operation_invalid' } /** The verdict the host puts on the failed operation's replay; undefined models an older host. */ let replayVerdict: AgentSessionOwnerVerdict | undefined @@ -125,12 +126,13 @@ beforeEach(() => { }) describe('structured launch after a host-failed create', () => { - it('fails with the host reason when the provider is proven gone, and Retry starts it under a new operation', async () => { + it('fails with the host refusal when the provider is proven gone, and Retry starts it under a new operation', async () => { const sessionId = launch() await settle(sessionId) expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe('failed') - expect(getStructuredAgentSessionLaunchFailureReason(WORKTREE, sessionId)).toBe(EXIT_REASON) + // The host's message is for its log; the Retry line words the refusal. + expect(getStructuredAgentSessionLaunchFailure(WORKTREE, sessionId)).toEqual(HOST_REFUSAL) expect(createdOperations.every((operation) => operation === failedOperation)).toBe(true) expect(retryStructuredAgentSessionLaunch(WORKTREE, sessionId)).toBe(true) @@ -147,7 +149,8 @@ describe('structured launch after a host-failed create', () => { const sessionId = launch() await settle(sessionId) expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe('failed') - expect(getStructuredAgentSessionLaunchFailureReason(WORKTREE, sessionId)).toBe(EXIT_REASON) + // The host's message is for its log; the Retry line words the refusal. + expect(getStructuredAgentSessionLaunchFailure(WORKTREE, sessionId)).toEqual(HOST_REFUSAL) expect(createdOperations).toEqual([failedOperation]) expect(retryStructuredAgentSessionLaunch(WORKTREE, sessionId)).toBe(true) await settle(sessionId) diff --git a/src/renderer/src/lib/structured-agent-session-launch-failure.ts b/src/renderer/src/lib/structured-agent-session-launch-failure.ts new file mode 100644 index 00000000000..b9ad791a0c4 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-failure.ts @@ -0,0 +1,18 @@ +// What a failed chat start's Retry line may say: the host's refusal, never its message. + +import { readAgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentSessionRefusalFailure, + readAgentSessionErrorRefusal, + type AgentSessionWriteRefusal +} from '../../../shared/agent-session-write-failure' + +/** The host's refusal behind a failed launch; undefined when the failure carried none (a lost + * reply, a local fault, an older host). Reads a create error's own refusal or an RPC error's. */ +export function structuredLaunchFailure(error: unknown): AgentSessionWriteRefusal | undefined { + const refusal = + error instanceof Error && 'refusal' in error + ? readAgentSessionRefusalReference(error.refusal) + : readAgentSessionErrorRefusal(error) + return refusal ? agentSessionRefusalFailure(refusal) : undefined +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index 8fa72308538..3787ebe3608 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' type CallParams = { envelope?: { expectedRuntimeFence?: number | null } @@ -264,6 +265,41 @@ describe('picks made while a chat launches', () => { ]) }) + it('reports a refusal the host threw as its fact, never the bare code it carries as a message', async () => { + mocks.launch.mockResolvedValue({ sessionId: SESSION_ID, fence: 1 }) + startStructuredAgentLaunch(WORKTREE_ID, 'codex') + const pick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-missing') + await settle() + setOptionReplies[0]!.resolve( + Promise.reject( + new RuntimeRpcCallError({ + id: 'request-1', + ok: false, + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + ) + + await expect(pick).resolves.toEqual({ + kind: 'refused', + failure: { + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + }) + }) + it('keeps picks held through a failed start and applies them to the retry', async () => { mocks.launch .mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 36754757504..b087264ce25 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -23,9 +23,9 @@ import { } from './structured-agent-session-launch-registry' import { agentSessionRefusalFailure, - agentSessionRpcErrorFailure, + agentSessionThrownFailure, type AgentSessionWriteFailure -} from '../../../shared/agent-session-refusal-notice' +} from '../../../shared/agent-session-write-failure' /** The options a launch starts with, replaced whole so readers can compare by identity. */ export type StructuredLaunchSelection = { @@ -110,7 +110,8 @@ async function setLaunchOption( } catch (error) { return { kind: 'refused', - failure: agentSessionRpcErrorFailure( + failure: agentSessionThrownFailure( + error, error instanceof RuntimeRpcCallError ? error.code : undefined ) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 20b95a89b69..84c8fa6e191 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -8,6 +8,7 @@ import { structuredAgentSessionSendRequest, type StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import { agentSessionRefusalFailure } from '../../../shared/agent-session-write-failure' import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' import { mutateStructuredAgentSessionLaunchPrompt, @@ -111,7 +112,7 @@ async function dispatchStructuredLaunchPrompt( mutateEntry(entry, (current) => requeueStructuredAgentSessionSendRefusal( current, - result.refusal.code, + agentSessionRefusalFailure(result.refusal), () => createStructuredAgentSessionOperationId(createBrowserUuid), entry.lastAttemptAt !== null ) diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index dcafea65803..2f444446e03 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -1,5 +1,6 @@ import { useSyncExternalStore } from 'react' import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' import type { StructuredLaunchRecoveryState } from './structured-agent-session-launch-recovery' import type { StructuredLaunchSelection } from './structured-agent-session-launch-options' @@ -26,8 +27,9 @@ export type StructuredLaunchState = StructuredLaunchRecoveryState & { /** Fixed by the caller that opened this launch so coalesced prompts use one delivery mode. */ promptDelivery: StructuredAgentLaunchOptions['promptDelivery'] callers: StructuredLaunchCallerGroup - /** Why the last attempt failed, shown beside Retry; the toast stays generic. */ - failureReason?: string + /** The host's refusal behind the last failed attempt, worded beside Retry; the toast stays + * generic. Absent when the failure named none. */ + failure?: AgentSessionWriteRefusal selection: StructuredLaunchSelection } @@ -176,25 +178,25 @@ export function getStructuredAgentSessionLaunchResumes(sessionId: string): boole return resumeFrom !== undefined } -export function getStructuredAgentSessionLaunchFailureReason( +export function getStructuredAgentSessionLaunchFailure( worktreeId: string, sessionId: string -): string | null { +): AgentSessionWriteRefusal | null { const state = getStructuredLaunchStateBySessionId(sessionId) return state && matchesLaunchWorktree(state, worktreeId) && launchStateLifecycle(state) === 'failed' - ? (state.failureReason ?? null) + ? (state.failure ?? null) : null } -export function useStructuredAgentSessionLaunchFailureReason( +export function useStructuredAgentSessionLaunchFailure( worktreeId: string, sessionId: string -): string | null { +): AgentSessionWriteRefusal | null { return useSyncExternalStore( subscribeStructuredAgentLaunchStatus, - () => getStructuredAgentSessionLaunchFailureReason(worktreeId, sessionId), + () => getStructuredAgentSessionLaunchFailure(worktreeId, sessionId), () => null ) } diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 5ad75c36ed6..4c62128813b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -27,6 +27,7 @@ import { } from '@/lib/structured-agent-session-launch-callers' import * as launchDraft from './structured-agent-session-launch-draft' import { trackStructuredLaunchFailureToast } from './structured-agent-session-launch-failure-toast' +import { structuredLaunchFailure } from './structured-agent-session-launch-failure' import { deleteStructuredLaunchStateIfCurrent, getStructuredLaunchState, @@ -51,7 +52,7 @@ export { retireStructuredAgentSessionLaunchCancellationTombstone, shouldRetainStructuredAgentSessionLaunchTab, subscribeStructuredAgentLaunchStatus, - useStructuredAgentSessionLaunchFailureReason, + useStructuredAgentSessionLaunchFailure, useStructuredAgentSessionLaunchLifecycle, type StructuredAgentLaunchStatus, type StructuredAgentSessionLaunchLifecycle @@ -138,7 +139,13 @@ function trackLaunchSettlement( } return } - state.failureReason = error instanceof Error ? error.message : String(error) + // The host's message is for its log; the Retry line words the refusal itself. + const failure = structuredLaunchFailure(error) + if (failure) { + state.failure = failure + } else { + delete state.failure + } if (error instanceof StructuredAgentSessionCreateRefusalError) { settleStructuredLaunchRefusal(state) } else if (!state.visibilityUnknown) { @@ -171,7 +178,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { state.intent = retryStructuredAgentSessionLaunchIntent(state.intent) } resetStructuredLaunchCallers(state) - delete state.failureReason + delete state.failure state.callers.outcome = 'pending' // A new create seeds from the settings of now; picks held through the failure still apply. state.selection = { ...state.selection, seed: state.intent.seedOptions } diff --git a/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts b/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts index 26bbe4b1ebd..e87fc615042 100644 --- a/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts +++ b/src/renderer/src/lib/workspace-activation-terminal-focus.test.ts @@ -1,209 +1,235 @@ -import type * as SyncRuntimeGraphModule from '@/runtime/sync-runtime-graph' -import { beforeEach, describe, expect, it, vi } from 'vitest' +// @vitest-environment happy-dom +import type { AppState } from '@/store/types' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const focusTerminalTabSurfaceMock = vi.hoisted(() => vi.fn()) -const focusRuntimeTerminalSurfaceMock = vi.hoisted(() => vi.fn()) -const getStateMock = vi.hoisted(() => vi.fn()) +globalThis.IS_REACT_ACT_ENVIRONMENT = true -vi.mock('@/lib/focus-terminal-tab-surface', () => ({ - focusTerminalTabSurface: focusTerminalTabSurfaceMock +const mocks = vi.hoisted(() => ({ + focus: vi.fn(), + getState: vi.fn(), + subscribe: vi.fn() })) - -vi.mock('@/runtime/sync-runtime-graph', () => ({ - focusRuntimeTerminalSurface: focusRuntimeTerminalSurfaceMock -})) - +vi.mock('@/runtime/sync-runtime-graph', () => ({ focusRuntimeTerminalSurface: mocks.focus })) vi.mock('@/store', () => ({ - useAppStore: { - getState: getStateMock - } + useAppStore: { getState: mocks.getState, subscribe: mocks.subscribe } })) import { queueWorkspaceActivationTerminalFocus } from './workspace-activation-terminal-focus' -const CHAT_VIEW_LEAF_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' - -type FocusState = { - activeWorktreeId: string | null - activeView: string - activeTabType: string - activeTabId: string | null -} - -let pendingFrame: (() => void) | null = null - -function setFocusState(state: FocusState): void { - getStateMock.mockImplementation(() => state) -} +let state: Pick< + AppState, + | 'activeWorktreeId' + | 'activeWorkspaceExecutionHostId' + | 'activeView' + | 'activeTabType' + | 'activeTabId' + | 'activeModal' +> +let frame: FrameRequestCallback | null +let notifyStore: () => void +let notifyMount: () => void +const unsubscribe = vi.fn() +const disconnect = vi.fn() function flushFrame(): void { - const frame = pendingFrame - pendingFrame = null - frame?.() + const callback = frame + frame = null + callback?.(0) } -describe('queueWorkspaceActivationTerminalFocus', () => { - beforeEach(() => { - vi.clearAllMocks() - pendingFrame = null - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - pendingFrame = () => callback(0) - return 1 - }) - focusRuntimeTerminalSurfaceMock.mockReturnValue(false) +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + frame = null + state = { + activeWorktreeId: 'wt-1', + activeWorkspaceExecutionHostId: null, + activeView: 'terminal', + activeTabType: 'terminal', + activeTabId: 'tab-1', + activeModal: 'none' + } + mocks.getState.mockImplementation(() => state) + mocks.subscribe.mockImplementation((listener) => { + notifyStore = listener + return unsubscribe }) + mocks.focus.mockReturnValue(false) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frame = callback + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', () => { + frame = null + }) + vi.stubGlobal( + 'MutationObserver', + class { + constructor(callback: () => void) { + notifyMount = callback + } + observe = vi.fn() + disconnect = disconnect + } + ) +}) +afterEach(() => { + document.dispatchEvent(new Event('pointerdown')) + document.body.replaceChildren() + vi.useRealTimers() + vi.unstubAllGlobals() +}) - it('focuses the primary tab from the activation result after the modal close frame', () => { - setFocusState({ - activeWorktreeId: 'wt-1', - activeView: 'terminal', - activeTabType: 'terminal', - activeTabId: 'tab-1' - }) - - queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) - - expect(focusRuntimeTerminalSurfaceMock).not.toHaveBeenCalled() +describe('workspace activation focus', () => { + it('focuses an existing terminal after the palette closes and cleans up', () => { + mocks.focus.mockReturnValue(true) + expect(queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' })).toBe(true) + expect(mocks.focus).not.toHaveBeenCalled() flushFrame() - - expect(focusRuntimeTerminalSurfaceMock).toHaveBeenCalledWith('tab-1', null, 'wt-1') - expect(focusTerminalTabSurfaceMock).toHaveBeenCalledWith('tab-1') + expect(mocks.focus).toHaveBeenCalledWith('tab-1', null, 'wt-1') + expect(unsubscribe).toHaveBeenCalledOnce() + expect(disconnect).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) }) - it('uses the adopted active terminal tab when activation did not create one', () => { - setFocusState({ - activeWorktreeId: 'wt-1', - activeView: 'terminal', - activeTabType: 'terminal', - activeTabId: 'tab-adopted' - }) - + it('waits for a sleeping terminal to mount instead of focusing another workspace', () => { queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: null }) flushFrame() - - expect(focusRuntimeTerminalSurfaceMock).toHaveBeenCalledWith('tab-adopted', null, 'wt-1') - expect(focusTerminalTabSurfaceMock).toHaveBeenCalledWith('tab-adopted') - }) - - it('does not fall back to DOM focus when the runtime surface handled focus', () => { - focusRuntimeTerminalSurfaceMock.mockReturnValue(true) - setFocusState({ - activeWorktreeId: 'wt-1', - activeView: 'terminal', - activeTabType: 'terminal', - activeTabId: 'tab-1' - }) - - queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) + expect(mocks.focus).toHaveBeenCalledTimes(1) + expect(frame).toBeNull() + mocks.focus.mockReturnValue(true) + notifyMount() flushFrame() - - expect(focusRuntimeTerminalSurfaceMock).toHaveBeenCalledWith('tab-1', null, 'wt-1') - expect(focusTerminalTabSurfaceMock).not.toHaveBeenCalled() + expect(mocks.focus).toHaveBeenLastCalledWith('tab-1', null, 'wt-1') + expect(unsubscribe).toHaveBeenCalledOnce() }) - // Why: a legacy native chat is a terminal tab whose xterm stays mounted under the chat portal. - // Activation must leave the caret in the composer, so neither the runtime path's xterm focus - // nor the DOM fallback may fire. Runs the real runtime surface, not the module mock. - it('leaves a chat-view terminal tab unfocused on both the runtime and DOM paths', async () => { - const runtime = await vi.importActual( - '@/runtime/sync-runtime-graph' - ) - focusRuntimeTerminalSurfaceMock.mockImplementation(runtime.focusRuntimeTerminalSurface) - const focus = vi.fn() - const pane = { - id: 1, - leafId: CHAT_VIEW_LEAF_ID, - container: { - querySelector: (selector: string) => - selector === '.native-chat-pane-shell' ? ({} as Element) : null - }, - terminal: { focus } - } - const manager = { - getPanes: () => [pane], - getActivePane: () => pane, - getLeafId: () => CHAT_VIEW_LEAF_ID, - getNumericIdForLeaf: () => pane.id, - setActivePane: vi.fn() - } - const unregister = runtime.registerRuntimeTerminalTab({ - tabId: 'tab-1', - worktreeId: 'wt-1', - getManager: () => manager as never, - getContainer: () => null, - getPtyIdForPane: () => null, - getTabWideAgentHintLeafId: () => null - }) - try { - setFocusState({ - activeWorktreeId: 'wt-1', - activeView: 'terminal', - activeTabType: 'terminal', - activeTabId: 'tab-1' - }) - - queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) - flushFrame() - - expect(focus).not.toHaveBeenCalled() - expect(focusTerminalTabSurfaceMock).not.toHaveBeenCalled() - } finally { - unregister() - } - }) - - // Why: #9939 — the return value is what stops the palette from running its whole-document - // fallback, which would grab the worktree the user just left, now mounted but hidden. - it('reports that it claimed focus for a terminal destination', () => { - setFocusState({ - activeWorktreeId: 'wt-1', - activeView: 'terminal', - activeTabType: 'terminal', - activeTabId: 'tab-adopted' - }) - + it('claims a workspace whose terminal tab is created asynchronously', () => { + state.activeTabId = null expect(queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: null })).toBe(true) - }) - - it('declines a destination whose restored surface is not a terminal', () => { - setFocusState({ - activeWorktreeId: 'wt-1', - activeView: 'terminal', - activeTabType: 'browser', - activeTabId: 'tab-adopted' - }) - - expect(queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: null })).toBe(false) flushFrame() - expect(focusRuntimeTerminalSurfaceMock).not.toHaveBeenCalled() - expect(focusTerminalTabSurfaceMock).not.toHaveBeenCalled() + expect(mocks.focus).not.toHaveBeenCalled() + state.activeTabId = 'restored-tab' + notifyStore() + flushFrame() + mocks.focus.mockReturnValue(true) + notifyMount() + flushFrame() + expect(mocks.focus).toHaveBeenLastCalledWith('restored-tab', null, 'wt-1') }) - it('declines a destination that has no terminal tab yet', () => { - setFocusState({ - activeWorktreeId: 'wt-1', - activeView: 'terminal', - activeTabType: 'terminal', - activeTabId: null - }) - - expect(queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: null })).toBe(false) - }) - - it('does not steal focus if the user leaves the created workspace first', () => { - const state: FocusState = { - activeWorktreeId: 'wt-1', - activeView: 'terminal', - activeTabType: 'terminal', - activeTabId: 'tab-1' - } - setFocusState(state) - + it.each([ + { activeWorktreeId: 'wt-2' }, + { activeWorkspaceExecutionHostId: 'ssh:second-host' }, + { activeView: 'settings' }, + { activeTabType: 'browser' }, + { activeTabId: 'tab-2' }, + { activeModal: 'worktree-palette' } + ] as const)('cancels when selection changes to %j during restoration', (change) => { queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) - state.activeWorktreeId = 'wt-2' flushFrame() + mocks.focus.mockClear() + state = { ...state, ...change } + notifyStore() + flushFrame() + notifyMount() + flushFrame() + expect(mocks.focus).not.toHaveBeenCalled() + expect(unsubscribe).toHaveBeenCalledOnce() + }) - expect(focusRuntimeTerminalSurfaceMock).not.toHaveBeenCalled() - expect(focusTerminalTabSurfaceMock).not.toHaveBeenCalled() + it('cancels on a user click while waiting', () => { + queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) + flushFrame() + document.dispatchEvent(new Event('pointerdown')) + mocks.focus.mockClear() + notifyMount() + flushFrame() + expect(mocks.focus).not.toHaveBeenCalled() + }) + + it('does not steal focus from an input claimed during restoration', () => { + queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) + const input = document.createElement('input') + document.body.append(input) + input.focus() + notifyMount() + flushFrame() + expect(mocks.focus).not.toHaveBeenCalled() + expect(document.activeElement).toBe(input) + }) + + it('retains focus through composer dialog close with a previously focused editable input', async () => { + const previousInput = document.createElement('input') + const container = document.createElement('div') + document.body.append(previousInput, container) + previousInput.focus() + const root = createRoot(container) + try { + // The composer uses a controlled modal without a DialogTrigger or close autofocus handler. + await act(async () => { + root.render( + createElement( + Dialog, + { open: true }, + createElement( + DialogContent, + { 'aria-describedby': undefined }, + createElement(DialogTitle, null, 'New workspace'), + createElement('input', { 'aria-label': 'Workspace name' }) + ) + ) + ) + }) + expect(document.activeElement?.getAttribute('aria-label')).toBe('Workspace name') + queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) + await act(async () => root.render(null)) + await act(async () => vi.advanceTimersByTime(0)) + expect(previousInput.isConnected).toBe(true) + expect(document.activeElement).not.toBe(previousInput) + mocks.focus.mockReturnValue(true) + flushFrame() + expect(mocks.focus).toHaveBeenCalledWith('tab-1', null, 'wt-1') + } finally { + await act(async () => root.unmount()) + } + }) + + it('keeps the post-palette request when terminal mounting briefly takes focus', () => { + queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) + const textarea = document.createElement('textarea') + textarea.className = 'xterm-helper-textarea' + document.body.append(textarea) + textarea.focus() + textarea.blur() + mocks.focus.mockReturnValue(true) + flushFrame() + expect(mocks.focus).toHaveBeenCalledWith('tab-1', null, 'wt-1') + }) + + it('expires a request if restoration fails', () => { + queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) + vi.advanceTimersByTime(30_000) + notifyMount() + flushFrame() + expect(mocks.focus).not.toHaveBeenCalled() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('cancels the previous request when a new activation is declined', () => { + queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: 'tab-1' }) + state.activeTabType = 'browser' + expect(queueWorkspaceActivationTerminalFocus('wt-1', { primaryTabId: null })).toBe(false) + notifyMount() + flushFrame() + expect(mocks.focus).not.toHaveBeenCalled() + }) + + it('declines a failed activation', () => { + expect(queueWorkspaceActivationTerminalFocus('wt-1', false)).toBe(false) + expect(mocks.subscribe).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/workspace-activation-terminal-focus.ts b/src/renderer/src/lib/workspace-activation-terminal-focus.ts index 8e7bcf03a33..7b36867461b 100644 --- a/src/renderer/src/lib/workspace-activation-terminal-focus.ts +++ b/src/renderer/src/lib/workspace-activation-terminal-focus.ts @@ -1,54 +1,94 @@ +import { shouldPreserveEditableFocus } from '@/components/terminal-pane/pane-helpers' import { useAppStore } from '@/store' -import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph' import type { ActivateAndRevealResult } from '@/lib/worktree-activation' -function resolveActivatedWorkspaceTerminalTabId( - worktreeId: string, - activation: ActivateAndRevealResult | false -): string | null { - const state = useAppStore.getState() - if (activation && activation.primaryTabId) { - return activation.primaryTabId - } - if ( - state.activeWorktreeId !== worktreeId || - state.activeView !== 'terminal' || - state.activeTabType !== 'terminal' - ) { - return null - } - return state.activeTabId -} +let cancelPendingFocus: (() => void) | null = null -// Why: returns false when the destination's restored surface is not a terminal, so callers can -// fall back instead of focusing an unrelated worktree's mounted-but-hidden terminal (#9939). +// A sleeping workspace can publish its tab and mount its terminal after the palette closes. export function queueWorkspaceActivationTerminalFocus( worktreeId: string, activation: ActivateAndRevealResult | false ): boolean { - const tabId = resolveActivatedWorkspaceTerminalTabId(worktreeId, activation) - if (!tabId) { + cancelPendingFocus?.() + const initial = useAppStore.getState() + if ( + !activation || + initial.activeWorktreeId !== worktreeId || + initial.activeView !== 'terminal' || + initial.activeTabType !== 'terminal' + ) { return false } - - requestAnimationFrame(() => { + let tabId = activation.primaryTabId ?? initial.activeTabId + const executionHostId = initial.activeWorkspaceExecutionHostId + const initialFocus = document.activeElement + const closingDialog = document.querySelector('[role="dialog"]') + let frameId: number | null = null + let finished = false + const cancel = (): void => { + if (finished) { + return + } + finished = true + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + observer.disconnect() + unsubscribe() + clearTimeout(timeout) + document.removeEventListener('pointerdown', cancel, true) + document.removeEventListener('focusin', onFocus, true) + if (cancelPendingFocus === cancel) { + cancelPendingFocus = null + } + } + const attempt = (): void => { + frameId = null const state = useAppStore.getState() if ( state.activeWorktreeId !== worktreeId || + state.activeWorkspaceExecutionHostId !== executionHostId || state.activeView !== 'terminal' || state.activeTabType !== 'terminal' || - state.activeTabId !== tabId + (tabId !== null && state.activeTabId !== tabId) || + state.activeModal !== 'none' ) { + cancel() return } - - // Why: creation closes a Radix dialog immediately after activation. Queue - // focus past that close so focus restoration cannot leave the user on the - // removed composer field after Cmd/Ctrl+Enter. - if (!focusRuntimeTerminalSurface(tabId, null, worktreeId)) { - focusTerminalTabSurface(tabId) + tabId ??= state.activeTabId + if (tabId && focusRuntimeTerminalSurface(tabId, null, worktreeId)) { + cancel() } + } + const schedule = (): void => { + if (!finished && frameId === null) { + frameId = requestAnimationFrame(attempt) + } + } + const onFocus = (): void => { + const active = document.activeElement + if ( + active !== initialFocus && + !closingDialog?.contains(active) && + shouldPreserveEditableFocus(active) + ) { + cancel() + } + } + const observer = new MutationObserver(schedule) + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style', 'data-state'] }) + const unsubscribe = useAppStore.subscribe(schedule) + const timeout = setTimeout(cancel, 30_000) + document.addEventListener('pointerdown', cancel, true) + document.addEventListener('focusin', onFocus, true) + cancelPendingFocus = cancel + schedule() return true } diff --git a/src/renderer/src/lib/workspace-session-browser-history.test.ts b/src/renderer/src/lib/workspace-session-browser-history.test.ts index 1a9bde2f605..8c6ef564e25 100644 --- a/src/renderer/src/lib/workspace-session-browser-history.test.ts +++ b/src/renderer/src/lib/workspace-session-browser-history.test.ts @@ -34,8 +34,7 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess worktreesByRepo: {}, lastKnownRelayPtyIdByTabId: {}, lastVisitedAtByWorktreeId: {}, - defaultTerminalTabsAppliedByWorktreeId: {}, - closedTerminalTabTombstonesByTabId: {} + defaultTerminalTabsAppliedByWorktreeId: {} } } diff --git a/src/renderer/src/lib/workspace-session-closed-tab-tombstones.test.ts b/src/renderer/src/lib/workspace-session-closed-tab-tombstones.test.ts deleted file mode 100644 index 522b079aff6..00000000000 --- a/src/renderer/src/lib/workspace-session-closed-tab-tombstones.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildWorkspaceSessionPatch } from './workspace-session-patch' -import { SESSION_RELEVANT_FIELDS } from './workspace-session' -import { CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS } from '../../../shared/closed-terminal-tab-tombstones' - -// Why browserPagesByWorkspace is stubbed: buildWorkspaceSessionPatch pre-filters staged browser tabs -// before field dispatch, so the minimal fixture has to carry the map it iterates. -function patchFor( - map: Record -): ReturnType { - return buildWorkspaceSessionPatch( - { browserPagesByWorkspace: {}, closedTerminalTabTombstonesByTabId: map } as never, - ['closedTerminalTabTombstonesByTabId'] - ) -} - -describe('closed-tab tombstones in the persisted session', () => { - it('is a session-relevant field, so a close on its own schedules a write', () => { - expect(SESSION_RELEVANT_FIELDS).toContain('closedTerminalTabTombstonesByTabId') - }) - - it('writes live tombstones and drops ones past the TTL', () => { - const now = Date.now() - expect( - patchFor({ - fresh: { closedAt: now, worktreeId: 'repo-1::/srv/app' }, - stale: { - closedAt: now - CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS - 1, - worktreeId: 'repo-1::/srv/app' - } - }).closedTerminalTabTombstonesByTabId - ).toEqual({ fresh: { closedAt: now, worktreeId: 'repo-1::/srv/app' } }) - }) - - it('omits the field entirely once the map empties', () => { - expect(patchFor({}).closedTerminalTabTombstonesByTabId).toBeUndefined() - }) -}) diff --git a/src/renderer/src/lib/workspace-session-closed-tab-tombstones.ts b/src/renderer/src/lib/workspace-session-closed-tab-tombstones.ts deleted file mode 100644 index 48b1cb1a727..00000000000 --- a/src/renderer/src/lib/workspace-session-closed-tab-tombstones.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { pruneClosedTerminalTabTombstones } from '../../../shared/closed-terminal-tab-tombstones' - -/** Applies the TTL/cap bound on the way to disk, and omits an empty map like its siblings. */ -export function buildPersistedClosedTerminalTabTombstones( - map: WorkspaceSessionState['closedTerminalTabTombstonesByTabId'] -): WorkspaceSessionState['closedTerminalTabTombstonesByTabId'] { - const pruned = pruneClosedTerminalTabTombstones(map, Date.now()) - return Object.keys(pruned).length > 0 ? pruned : undefined -} diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index 690e5917fd1..ba639bb4668 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -36,7 +36,6 @@ function createSnapshot( lastKnownRelayPtyIdByTabId: {}, lastVisitedAtByWorktreeId: {}, defaultTerminalTabsAppliedByWorktreeId: {}, - closedTerminalTabTombstonesByTabId: {}, ...overrides } } diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index 5cabd5b52ff..821c059bfa0 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -35,7 +35,6 @@ function createSnapshot( lastKnownRelayPtyIdByTabId: {}, lastVisitedAtByWorktreeId: {}, defaultTerminalTabsAppliedByWorktreeId: {}, - closedTerminalTabTombstonesByTabId: {}, ...overrides } } diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index 4537e1c570b..dc2947918c2 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -20,7 +20,6 @@ import { withoutStagedBrowserTabs } from './workspace-session-staged-browser-tab import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs' import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' -import { buildPersistedClosedTerminalTabTombstones } from './workspace-session-closed-tab-tombstones' type SessionRelevantField = keyof WorkspaceSessionSnapshot @@ -193,11 +192,6 @@ export function buildWorkspaceSessionPatch( ? snapshot.defaultTerminalTabsAppliedByWorktreeId : undefined } - if (changed.has('closedTerminalTabTombstonesByTabId')) { - patch.closedTerminalTabTombstonesByTabId = buildPersistedClosedTerminalTabTombstones( - snapshot.closedTerminalTabTombstonesByTabId - ) - } if (changed.has('sleepingAgentSessionsByPaneKey')) { patch.sleepingAgentSessionsByPaneKey = buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index ce26d33ec82..9aadf057964 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -36,7 +36,6 @@ describe('SESSION_RELEVANT_FIELDS', () => { lastKnownRelayPtyIdByTabId: true, lastVisitedAtByWorktreeId: true, defaultTerminalTabsAppliedByWorktreeId: true, - closedTerminalTabTombstonesByTabId: true, sleepingAgentSessionsByPaneKey: true, clientHostedBrowserCloseIntentsByEnvironment: true, pendingReconnectPtyIdByTabId: true, diff --git a/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts b/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts index 19050b9ff67..13cc1baf507 100644 --- a/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts +++ b/src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts @@ -682,8 +682,7 @@ describe('ssh host partition remote-workspace round trip', () => { new Set([WORKTREE_ID]), read.session.tabsByWorktree, new Set(), - SSH_HOST_ID, - 1 + SSH_HOST_ID ) expect(merged.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-runtime']) @@ -718,8 +717,7 @@ describe('ssh host partition remote-workspace round trip', () => { new Set([WORKTREE_ID]), read.session.tabsByWorktree, new Set(), - SSH_HOST_ID, - 2 + SSH_HOST_ID ) expect(merged.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-runtime']) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index e35e2194f8e..4bf57f6d82f 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -11,7 +11,6 @@ import type { OpenFile } from '../store/slices/editor' import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs' import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' -import { buildPersistedClosedTerminalTabTombstones } from './workspace-session-closed-tab-tombstones' import { buildActiveConnectionIdsAtShutdown } from './workspace-session-reconnect-targets' import { withoutStagedBrowserTabs } from './workspace-session-staged-browser-tabs' import { buildBrowserSessionData } from './workspace-session-browser-tabs' @@ -57,7 +56,6 @@ export type WorkspaceSessionSnapshot = Pick< | 'lastKnownRelayPtyIdByTabId' | 'lastVisitedAtByWorktreeId' | 'defaultTerminalTabsAppliedByWorktreeId' - | 'closedTerminalTabTombstonesByTabId' > & { activeWorkspaceExecutionHostId?: AppState['activeWorkspaceExecutionHostId'] sleepingAgentSessionsByPaneKey?: AppState['sleepingAgentSessionsByPaneKey'] @@ -100,7 +98,6 @@ export const SESSION_RELEVANT_FIELDS = [ 'lastKnownRelayPtyIdByTabId', 'lastVisitedAtByWorktreeId', 'defaultTerminalTabsAppliedByWorktreeId', - 'closedTerminalTabTombstonesByTabId', 'sleepingAgentSessionsByPaneKey', 'clientHostedBrowserCloseIntentsByEnvironment', 'pendingReconnectPtyIdByTabId', @@ -330,9 +327,6 @@ export function buildWorkspaceSessionPayload( Object.keys(snapshot.defaultTerminalTabsAppliedByWorktreeId).length > 0 ? snapshot.defaultTerminalTabsAppliedByWorktreeId : undefined, - closedTerminalTabTombstonesByTabId: buildPersistedClosedTerminalTabTombstones( - snapshot.closedTerminalTabTombstonesByTabId - ), ...buildSleepingAgentSessionData(snapshot), // Why unconditional rather than omit-when-empty: a full write replaces the persisted object, // so an emptied map has to be written as empty or the last replay never sticks. diff --git a/src/renderer/src/lib/worktree-activation-default-tabs.test.ts b/src/renderer/src/lib/worktree-activation-default-tabs.test.ts index 3b990948340..a6e5e5136a8 100644 --- a/src/renderer/src/lib/worktree-activation-default-tabs.test.ts +++ b/src/renderer/src/lib/worktree-activation-default-tabs.test.ts @@ -21,8 +21,11 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) - it('does not recreate a terminal after an explicit empty state was persisted', () => { - const store = createMockStore({ tabsByWorktree: { 'wt-1': [] } }) + it('does not recreate a terminal after the user closed the last one', () => { + const store = createMockStore({ + tabsByWorktree: { 'wt-1': [] }, + closedTerminalTabTombstonesByTabId: { closed: { closedAt: Date.now(), worktreeId: 'wt-1' } } + }) ensureWorktreeHasInitialTerminal(store, 'wt-1') diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts index 4d2b43e26bd..13cedba66df 100644 --- a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts +++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts @@ -24,9 +24,12 @@ afterEach(() => { }) /** The state a workspace lands in once its last terminal is closed: the row survives as an - * explicit empty list rather than disappearing. */ + * explicit empty list rather than disappearing, beside the close's record. */ function seedClosedLastTerminal(worktreeId: string): void { - useAppStore.setState({ tabsByWorktree: { [worktreeId]: [] } }) + useAppStore.setState({ + tabsByWorktree: { [worktreeId]: [] }, + closedTerminalTabTombstonesByTabId: { closed: { closedAt: Date.now(), worktreeId } } + }) const { renderableTabCount } = useAppStore.getState().reconcileWorktreeTabModel(worktreeId) expect(renderableTabCount).toBe(0) } diff --git a/src/renderer/src/lib/worktree-activation-store-contract.ts b/src/renderer/src/lib/worktree-activation-store-contract.ts index 63ce180ffed..3608c9def1c 100644 --- a/src/renderer/src/lib/worktree-activation-store-contract.ts +++ b/src/renderer/src/lib/worktree-activation-store-contract.ts @@ -8,9 +8,11 @@ import type { } from '../../../shared/agent-session-resume' import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner' import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload' +import type { ClosedTerminalTabTombstonesByTabId } from '../../../shared/closed-terminal-tab-tombstones' export type WorktreeActivationStore = Partial & { tabsByWorktree: Record + closedTerminalTabTombstonesByTabId?: ClosedTerminalTabTombstonesByTabId defaultTerminalTabsAppliedByWorktreeId: Record createTab: ( worktreeId: string, diff --git a/src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts b/src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts new file mode 100644 index 00000000000..cd563d9cbe7 --- /dev/null +++ b/src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' +import { + createMockStore, + registerWorktreeActivationReset +} from './worktree-activation-test-harness' +import { + CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS, + MAX_CLOSED_TERMINAL_TAB_TOMBSTONES, + recordClosedTerminalTabTombstone, + type ClosedTerminalTabTombstonesByTabId +} from '../../../shared/closed-terminal-tab-tombstones' + +registerWorktreeActivationReset() + +const WT = 'wt-1' + +/** Startup hydration's seeding decision: no reseed opt-in, so only the row and records decide. */ +function seeds( + tabsByWorktree: Record, + closedTerminalTabTombstonesByTabId?: ClosedTerminalTabTombstonesByTabId, + opts?: { reseedEmptiedWorkspace?: boolean } +): boolean { + const store = createMockStore({ tabsByWorktree, closedTerminalTabTombstonesByTabId }) + ensureWorktreeHasInitialTerminal(store, WT, undefined, undefined, undefined, undefined, opts) + return store.createTab.mock.calls.length > 0 +} + +function closedAt(closedAtMs: number): ClosedTerminalTabTombstonesByTabId { + return { 'closed-tab': { closedAt: closedAtMs, worktreeId: WT, reason: 'user' } } +} + +describe('seeding an empty workspace reads close records', () => { + it('an empty row with a close record stays empty', () => { + expect(seeds({ [WT]: [] }, closedAt(Date.now()))).toBe(false) + }) + + it('a legacy empty row with no record seeds', () => { + expect(seeds({ [WT]: [] })).toBe(true) + }) + + it('an empty row whose only record is past its TTL seeds', () => { + expect( + seeds({ [WT]: [] }, closedAt(Date.now() - CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS - 1)) + ).toBe(true) + }) + + it('an empty row whose record the per-host cap evicted seeds', () => { + const now = Date.now() + let records = closedAt(now - 1_000) + for (let index = 0; index < MAX_CLOSED_TERMINAL_TAB_TOMBSTONES; index += 1) { + records = recordClosedTerminalTabTombstone( + records, + `other-${index}`, + { worktreeId: 'wt-other', reason: 'user' }, + now + ) + } + expect(records['closed-tab']).toBeUndefined() + expect(seeds({ [WT]: [] }, records)).toBe(true) + }) + + // Known weakness until every membership shrink is a close: an older close plus an empty row + // written by something that is not a close still reads as emptied on purpose. + it('a stale record from an earlier close suppresses the startup seed; activation reseeds', () => { + const earlierClose = closedAt(Date.now() - 60_000) + expect(seeds({ [WT]: [] }, earlierClose)).toBe(false) + expect(seeds({ [WT]: [] }, earlierClose, { reseedEmptiedWorkspace: true })).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index f9c2844d472..b0a6296b30e 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/worktree/launch-types' import type { ExecutionHostId } from '../../../shared/execution-host' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' +import { isTerminalWorkspaceEmptiedOnPurpose } from '../../../shared/closed-terminal-tab-tombstones' import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing' import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command' import { agentKindToTuiAgent } from '../../../shared/agent-kind' @@ -174,7 +175,7 @@ export function ensureWorktreeHasInitialTerminal( // terminal is added; and closeTabPreservingPty (use-terminal-pane-lifecycle.ts) skips both // deactivation hooks for pane moves and retirement, where re-seeding is the wanted outcome. const shouldHonourClosedTerminalTombstone = - Object.hasOwn(store.tabsByWorktree, worktreeId) && opts?.reseedEmptiedWorkspace !== true + isTerminalWorkspaceEmptiedOnPurpose(store, worktreeId) && opts?.reseedEmptiedWorkspace !== true // Why: an execution host that has not answered is not a host with no terminals; seeding into that // gap is what adds a tab per launch (STA-4658). Explicit launch work below is a request to create // a terminal now, so it stays ungated. diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts index f25aa9868a9..24393546a81 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume' import { + AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, @@ -55,6 +57,7 @@ describe('agentResumeHostAuthorityCapability', () => { ).toEqual({ claude: undefined, codex: undefined, + qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, gemini: undefined, antigravity: undefined, opencode: undefined, @@ -69,7 +72,8 @@ describe('agentResumeHostAuthorityCapability', () => { copilot: undefined, muse: AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, - kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY + kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + dsh: AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY }) }) }) diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts index ddc59b5ae98..efec3fe38b1 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -2,6 +2,8 @@ import type { ResumableTuiAgent } from '../../../shared/agent-session-resume' import type { TuiAgent } from '../../../shared/tui-agent' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, @@ -19,6 +21,7 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = { // These shipped inside agent-session.host-authority.v1's enum, so the generic probe covers them. claude: undefined, codex: undefined, + qoder: AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, gemini: undefined, antigravity: undefined, opencode: undefined, @@ -36,7 +39,8 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = { muse: AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, zcode: AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY, omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, - kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY + kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + dsh: AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY } satisfies Record export function agentResumeHostAuthorityCapability( diff --git a/src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts b/src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts index 39683bf2791..a2eff98b6c6 100644 --- a/src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts +++ b/src/renderer/src/runtime/focus-runtime-terminal-surface-chat-view.test.ts @@ -66,13 +66,15 @@ function registerChatViewTab(covered: boolean): { focus: ReturnType setActivePane: ReturnType } { - const focus = vi.fn() + const textarea = document.createElement('textarea') + document.body.append(textarea) + const focus = vi.fn(() => textarea.focus()) const setActivePane = vi.fn() const pane = { id: PANE_ID, leafId: LEAF_ID, container: makePaneContainer(covered), - terminal: { focus } + terminal: { focus, textarea } } const manager = { getPanes: () => [pane], @@ -99,6 +101,7 @@ function registerChatViewTab(covered: boolean): { // under the chat portal, so focusing it drags the caret out of the composer (#9939 twin). describe('focusRuntimeTerminalSurface on a chat-view pane', () => { afterEach(() => { + document.body.replaceChildren() while (unregisterCallbacks.length > 0) { unregisterCallbacks.pop()?.() } diff --git a/src/renderer/src/runtime/focus-runtime-terminal-surface-readiness.test.ts b/src/renderer/src/runtime/focus-runtime-terminal-surface-readiness.test.ts new file mode 100644 index 00000000000..6430ea97c1e --- /dev/null +++ b/src/renderer/src/runtime/focus-runtime-terminal-surface-readiness.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { focusRuntimeTerminalSurface, registerRuntimeTerminalTab } from './sync-runtime-graph' + +let unregister: (() => void) | undefined + +afterEach(() => { + unregister?.() + document.body.replaceChildren() +}) + +describe('terminal focus readiness', () => { + function register(hasPane: boolean, hasTextarea = true) { + const textarea = document.createElement('textarea') + const pane = { + id: 1, + container: document.createElement('div'), + terminal: { + textarea: hasTextarea ? textarea : undefined, + focus: vi.fn(() => textarea.focus()) + } + } + const manager = { + getActivePane: () => (hasPane ? pane : undefined), + getPanes: () => (hasPane ? [pane] : []) + } + unregister = registerRuntimeTerminalTab({ + tabId: 'restoring-tab', + worktreeId: 'restoring-workspace', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Focus reads only getActivePane/getPanes and the pane's container/terminal. + getManager: () => manager as unknown as PaneManager, + getContainer: () => null, + getPtyIdForPane: () => null, + getTabWideAgentHintLeafId: () => null + }) + return textarea + } + + it('does not report success before the active pane exists', () => { + register(false) + expect(focusRuntimeTerminalSurface('restoring-tab', null, 'restoring-workspace')).toBe(false) + }) + + it('does not report success before the terminal textarea exists', () => { + register(true, false) + expect(focusRuntimeTerminalSurface('restoring-tab', null, 'restoring-workspace')).toBe(false) + }) + + it('does not consume focus until the terminal can receive it', () => { + const textarea = register(true) + expect(focusRuntimeTerminalSurface('restoring-tab', null, 'restoring-workspace')).toBe(false) + document.body.append(textarea) + expect(focusRuntimeTerminalSurface('restoring-tab', null, 'restoring-workspace')).toBe(true) + expect(document.activeElement).toBe(textarea) + }) +}) diff --git a/src/renderer/src/runtime/runtime-host-contact-regained.test.ts b/src/renderer/src/runtime/runtime-host-contact-regained.test.ts new file mode 100644 index 00000000000..5daabf37790 --- /dev/null +++ b/src/renderer/src/runtime/runtime-host-contact-regained.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeStatus } from '../../../shared/runtime-types' + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), dismiss: vi.fn() } +})) + +vi.mock('@/store', async () => { + const { create } = await import('zustand') + const { createRuntimeStatusSlice } = await import('@/store/slices/runtime-status') + type Slice = ReturnType + return { + useAppStore: create()((...a) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the slice reads only its own fields. + createRuntimeStatusSlice(...(a as unknown as Parameters)) + ) + } +}) + +import { useAppStore } from '@/store' +import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' +import { subscribeRuntimeHostContactRegained } from './runtime-host-contact-regained' + +function makeStatus(runtimeId: string): RuntimeStatus { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture carries only the fields the slice reads. + return { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 3 + } as RuntimeStatus +} + +let lastVerified: RuntimeStatus | null = null + +// As the host-status snapshot does: an outage keeps the last verified status, so a same-runtime +// return moves only the contact epoch, not the connection generation. +function publish(status: RuntimeStatus | null, checkedAt: number): void { + lastVerified = status ?? lastVerified + useAppStore.getState().setRuntimeEnvironmentStatus('env-a', { + status, + checkedAt, + snapshot: { + environmentId: 'env-a', + pairingRevision: 1, + sequence: checkedAt, + checkedAt, + status: lastVerified, + verification: status ? 'verified' : 'unavailable', + transport: status ? 'ready' : 'disconnected' + } + }) +} + +beforeEach(() => { + clearRuntimeEnvironmentConnectionGenerationsForTests() + vi.stubGlobal('window', { api: {}, dispatchEvent: vi.fn() }) + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) + lastVerified = null +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('subscribeRuntimeHostContactRegained', () => { + it('fires when the runtime answers again after an outage, and not while it stays down', () => { + publish(makeStatus('runtime-a'), 1) + const listener = vi.fn() + const stop = subscribeRuntimeHostContactRegained('env-a', listener) + + publish(null, 2) + publish(null, 3) + expect(listener).not.toHaveBeenCalled() + + // Same runtime back: only the contact epoch moves. + const generation = useAppStore + .getState() + .runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + publish(makeStatus('runtime-a'), 4) + expect( + useAppStore.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration + ).toBe(generation) + expect(listener).toHaveBeenCalledTimes(1) + publish(makeStatus('runtime-a'), 5) + expect(listener).toHaveBeenCalledTimes(1) + + // A restarted runtime: the connection generation moves. + publish(makeStatus('runtime-b'), 6) + expect(listener).toHaveBeenCalledTimes(2) + + stop() + publish(null, 7) + publish(makeStatus('runtime-b'), 8) + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('ignores other environments', () => { + publish(makeStatus('runtime-a'), 1) + const listener = vi.fn() + const stop = subscribeRuntimeHostContactRegained('env-b', listener) + publish(null, 2) + publish(makeStatus('runtime-a'), 3) + expect(listener).not.toHaveBeenCalled() + stop() + }) +}) diff --git a/src/renderer/src/runtime/runtime-host-contact-regained.ts b/src/renderer/src/runtime/runtime-host-contact-regained.ts new file mode 100644 index 00000000000..b829f51d99a --- /dev/null +++ b/src/renderer/src/runtime/runtime-host-contact-regained.ts @@ -0,0 +1,31 @@ +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' + +function contactMarks(state: AppState, environmentId: string): string | null { + const entry = state.runtimeStatusByEnvironmentId.get(environmentId) + return entry ? `${entry.hostContactEpoch ?? 0}:${entry.connectionGeneration ?? 0}` : null +} + +/** + * Calls `listener` each time this client regains contact with an environment's runtime: the + * same-runtime return (`hostContactEpoch`) or a new runtime session (`connectionGeneration`), + * both published by runtime-status.ts. + */ +export function subscribeRuntimeHostContactRegained( + environmentId: string, + listener: () => void +): () => void { + let lastMarks = contactMarks(useAppStore.getState(), environmentId) + return useAppStore.subscribe((state, previousState) => { + if (state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId) { + return + } + const marks = contactMarks(state, environmentId) + // Why keep the last marks across a missing entry: a cleared status that returns is a reconnect. + if (marks === null || marks === lastMarks) { + return + } + lastMarks = marks + listener() + }) +} diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts index 7f54f5873bc..ded589e26b1 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -110,7 +110,12 @@ describe('runtime terminal owner routing', () => { it('sends input through the PTY owning environment instead of the active one', async () => { expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) await vi.waitFor(() => { @@ -341,7 +346,12 @@ describe('runtime terminal owner routing', () => { }) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) await vi.waitFor(() => { @@ -372,7 +382,12 @@ describe('runtime terminal owner routing', () => { useAppStore.setState({ lastTerminalInputAtByPaneKey: {} }) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) const nextLeafId = '22222222-2222-4222-8222-222222222222' useAppStore.setState({ @@ -417,7 +432,12 @@ describe('runtime terminal owner routing', () => { }) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', 'x') + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + 'x', + 'driving' + ) ).toBe(true) await vi.waitFor(() => { @@ -436,7 +456,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-stale', - 'x' + 'x', + 'driving' ) ).resolves.toBe(false) }) @@ -452,7 +473,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', - 'x' + 'x', + 'driving' ) ).resolves.toBe(false) @@ -472,17 +494,19 @@ describe('runtime terminal owner routing', () => { localWriteAccepted.mockResolvedValue(true) await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x') + sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x', 'driving') ).resolves.toBe(true) - expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x') + expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x', 'driving') expect(localWrite).not.toHaveBeenCalled() }) it('rejects oversized fire-and-forget local input before IPC writes', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) - expect(sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text)).toBe(false) + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') + ).toBe(false) expect(localWrite).not.toHaveBeenCalled() expect(localWriteAccepted).not.toHaveBeenCalled() @@ -492,7 +516,12 @@ describe('runtime terminal owner routing', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) expect( - sendRuntimePtyInput({ activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', text) + sendRuntimePtyInput( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1', + text, + 'driving' + ) ).toBe(false) expect(runtimeTransportCall).not.toHaveBeenCalled() @@ -504,14 +533,14 @@ describe('runtime terminal owner routing', () => { try { const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - expect(sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text)).toBe( - true - ) + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') + ).toBe(true) expect(localWrite).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(0) - expect(localWrite).toHaveBeenCalledWith('local-pty', text) + expect(localWrite).toHaveBeenCalledWith('local-pty', text, 'driving') expect(localWriteAccepted).not.toHaveBeenCalled() } finally { vi.useRealTimers() @@ -523,9 +552,9 @@ describe('runtime terminal owner routing', () => { try { const text = makeByteOversizedTerminalInput() - expect(sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text)).toBe( - true - ) + expect( + sendRuntimePtyInput({ activeRuntimeEnvironmentId: null }, 'local-pty', text, 'driving') + ).toBe(true) await vi.runAllTimersAsync() @@ -540,7 +569,12 @@ describe('runtime terminal owner routing', () => { const text = 'x'.repeat(TERMINAL_INPUT_MAX_BYTES + 1) await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', text) + sendRuntimePtyInputVerified( + { activeRuntimeEnvironmentId: null }, + 'local-pty', + text, + 'driving' + ) ).resolves.toBe(false) expect(localWriteAccepted).not.toHaveBeenCalled() @@ -555,7 +589,8 @@ describe('runtime terminal owner routing', () => { const accepted = sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: null }, 'local-pty', - text + text, + 'driving' ) expect(localWriteAccepted).not.toHaveBeenCalled() @@ -563,7 +598,7 @@ describe('runtime terminal owner routing', () => { await vi.advanceTimersByTimeAsync(0) await expect(accepted).resolves.toBe(true) - expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', text) + expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', text, 'driving') expect(localWrite).not.toHaveBeenCalled() } finally { vi.useRealTimers() @@ -577,7 +612,8 @@ describe('runtime terminal owner routing', () => { const accepted = sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: null }, 'local-pty', - text + text, + 'driving' ) await vi.runAllTimersAsync() @@ -612,7 +648,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', - 'x' + 'x', + 'driving' ) ).resolves.toBe(true) @@ -643,7 +680,8 @@ describe('runtime terminal owner routing', () => { sendRuntimePtyInputVerified( { activeRuntimeEnvironmentId: 'env-2' }, 'remote:env-1@@terminal-1', - 'x' + 'x', + 'driving' ) ).resolves.toBe(false) @@ -829,10 +867,10 @@ describe('runtime terminal owner routing', () => { localWriteAccepted.mockResolvedValue(false) await expect( - sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x') + sendRuntimePtyInputVerified({ activeRuntimeEnvironmentId: null }, 'local-pty', 'x', 'driving') ).resolves.toBe(true) - expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x') - expect(localWrite).toHaveBeenCalledWith('local-pty', 'x') + expect(localWriteAccepted).toHaveBeenCalledWith('local-pty', 'x', 'driving') + expect(localWrite).toHaveBeenCalledWith('local-pty', 'x', 'driving') }) }) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index b34d75f4555..e235bb5adef 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -20,6 +20,7 @@ export type { ClientOnlyUnverifiableInspection, ClientOnlyUnverifiableReason } from '../../../shared/terminal-process-inspection' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' export type RuntimeTerminalProcessInspection = TerminalProcessInspection @@ -216,7 +217,8 @@ export async function confirmRuntimeTerminalForegroundProcess( export function sendRuntimePtyInput( settings: Pick | null | undefined, ptyId: string, - data: string + data: string, + inputKind: TerminalInputKind ): boolean { const tooLarge = isRuntimePtyInputTooLarge(data) if (tooLarge === true) { @@ -228,19 +230,22 @@ export function sendRuntimePtyInput( void tooLarge .then((resolvedTooLarge) => { if (!resolvedTooLarge) { - sendRuntimePtyInputWithinLimit(settings, ptyId, data) + sendRuntimePtyInputWithinLimit(settings, ptyId, data, inputKind) } }) .catch(() => {}) return true } - return sendRuntimePtyInputWithinLimit(settings, ptyId, data) + return sendRuntimePtyInputWithinLimit(settings, ptyId, data, inputKind) } +// Why the kind reaches only the local write: terminal.send has no launch kind, and its query-reply +// kind is for mobile clients, so the host classifies a desktop's environment write by its bytes. function sendRuntimePtyInputWithinLimit( settings: Pick | null | undefined, ptyId: string, - data: string + data: string, + inputKind: TerminalInputKind ): boolean { const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) const target = ownerEnvironmentId @@ -248,7 +253,7 @@ function sendRuntimePtyInputWithinLimit( : getActiveRuntimeTarget(settings) const terminal = getRemoteRuntimeTerminalHandle(ptyId) if (target.kind !== 'environment' || !terminal) { - window.api.pty.write(ptyId, data) + window.api.pty.write(ptyId, data, inputKind) recordRuntimeTerminalInputForPtyId(ptyId) return true } @@ -274,7 +279,8 @@ function sendRuntimePtyInputWithinLimit( export async function sendRuntimePtyInputVerified( settings: Pick | null | undefined, ptyId: string, - data: string + data: string, + inputKind: TerminalInputKind ): Promise { const tooLarge = isRuntimePtyInputTooLarge(data) if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) { @@ -286,9 +292,9 @@ export async function sendRuntimePtyInputVerified( : getActiveRuntimeTarget(settings) const terminal = getRemoteRuntimeTerminalHandle(ptyId) if (target.kind !== 'environment' || !terminal) { - const accepted = await window.api.pty.writeAccepted(ptyId, data) + const accepted = await window.api.pty.writeAccepted(ptyId, data, inputKind) if (!accepted) { - window.api.pty.write(ptyId, data) + window.api.pty.write(ptyId, data, inputKind) // Why: SSH/local fallback writes are fire-and-forget. Callers use this // boolean to continue UX flow, while hook telemetry confirms real turns. recordRuntimeTerminalInputForPtyId(ptyId) diff --git a/src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts b/src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts index 97d17f3fc47..410e8dea763 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-terminal-registration-ownership.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { describe, expect, it, vi } from 'vitest' import { buildMobileSessionTabSnapshots, @@ -77,11 +78,24 @@ describe('runtime terminal registration ownership', () => { }) it('focuses the requested worktree when tab ids collide', () => { - const focusA = vi.fn() - const focusB = vi.fn() + const textareaA = document.createElement('textarea') + const textareaB = document.createElement('textarea') + document.body.append(textareaA, textareaB) + const focusA = vi.fn(() => textareaA.focus()) + const focusB = vi.fn(() => textareaB.focus()) const container = { querySelector: () => null } - const paneA = { id: 1, leafId: LEAF_A, container, terminal: { focus: focusA } } - const paneB = { id: 1, leafId: LEAF_B, container, terminal: { focus: focusB } } + const paneA = { + id: 1, + leafId: LEAF_A, + container, + terminal: { focus: focusA, textarea: textareaA } + } + const paneB = { + id: 1, + leafId: LEAF_B, + container, + terminal: { focus: focusB, textarea: textareaB } + } const managerA = { getPanes: () => [paneA], getActivePane: () => paneA, @@ -113,9 +127,12 @@ describe('runtime terminal registration ownership', () => { try { expect(focusRuntimeTerminalSurface(TAB_ID, null, WORKTREE_B)).toBe(true) expect(focusB).toHaveBeenCalledOnce() + expect(document.activeElement).toBe(textareaB) expect(focusA).not.toHaveBeenCalled() expect(focusRuntimeTerminalSurface(TAB_ID)).toBe(false) } finally { + textareaA.remove() + textareaB.remove() unregisterB() unregisterA() } diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 30504422fac..8267320e430 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -82,8 +82,12 @@ export function focusRuntimeTerminalSurface( if (activePaneIsCoveredByNativeChat(manager)) { return true } - manager.getActivePane()?.terminal.focus() - return true + const pane = manager.getActivePane() + if (!pane) { + return false + } + pane.terminal.focus() + return !!pane.terminal.textarea && document.activeElement === pane.terminal.textarea } const resolution = resolveLeafIdForManager(tabId, leafId, manager) if (resolution.status !== 'resolved') { diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index d62b48527b3..1ef7cfa5d6d 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -25,7 +25,10 @@ export { consumePendingWebRuntimeSplitMirrorTelemetry, closeWebRuntimeTerminal, updateWebRuntimePaneLayout, - setWebRuntimeTabProps, - clearWebRuntimeTerminalBuffer + setWebRuntimeTabProps } from './web-runtime-terminal-actions' +export { + clearWebRuntimeTerminalBuffer, + resetWebRuntimeTerminalInputModes +} from './web-runtime-terminal-buffer-actions' export type { WebRuntimeSplitSource } from './web-runtime-split-focus' diff --git a/src/renderer/src/runtime/web-runtime-terminal-actions.ts b/src/renderer/src/runtime/web-runtime-terminal-actions.ts index e12e9941492..0ae704640f0 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-actions.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-actions.ts @@ -279,32 +279,3 @@ export function setWebRuntimeTabProps(args: { }) return true } - -// Why: local pane.terminal.clear() is undone by the next host snapshot replay; clear the host buffer so it sticks. -export function clearWebRuntimeTerminalBuffer(ptyId: string | null | undefined): boolean { - if (!ptyId) { - return false - } - const remote = parseRemoteRuntimePtyId(ptyId) - const environmentId = remote?.environmentId?.trim() - if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) { - return false - } - void window.api.runtimeEnvironments - .call({ - selector: environmentId, - method: 'terminal.clearBuffer', - params: { terminal: remote.handle }, - timeoutMs: 15_000 - }) - .then((response) => { - unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ clear: unknown }>) - }) - .catch((error) => { - console.warn( - '[web-runtime-session] failed to clear terminal buffer:', - error instanceof Error ? error.message : String(error) - ) - }) - return true -} diff --git a/src/renderer/src/runtime/web-runtime-terminal-buffer-actions.ts b/src/renderer/src/runtime/web-runtime-terminal-buffer-actions.ts new file mode 100644 index 00000000000..8759f2feaeb --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-terminal-buffer-actions.ts @@ -0,0 +1,49 @@ +import { unwrapRuntimeRpcResult } from './runtime-rpc-client' +import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' +import { isWebRuntimeSessionActive } from './web-runtime-session-environment' + +// Why: local pane.terminal.clear() is undone by the next host snapshot replay; clear the host buffer so it sticks. +export function clearWebRuntimeTerminalBuffer(ptyId: string | null | undefined): boolean { + return callWebRuntimeTerminalAction(ptyId, 'terminal.clearBuffer', 'clear terminal buffer') +} + +// Why: same as clear; the host's snapshot would re-arm the modes on the next replay. +export function resetWebRuntimeTerminalInputModes(ptyId: string | null | undefined): boolean { + return callWebRuntimeTerminalAction( + ptyId, + 'terminal.resetInputModes', + 'reset terminal input modes' + ) +} + +function callWebRuntimeTerminalAction( + ptyId: string | null | undefined, + method: 'terminal.clearBuffer' | 'terminal.resetInputModes', + action: string +): boolean { + if (!ptyId) { + return false + } + const remote = parseRemoteRuntimePtyId(ptyId) + const environmentId = remote?.environmentId?.trim() + if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) { + return false + } + void window.api.runtimeEnvironments + .call({ + selector: environmentId, + method, + params: { terminal: remote.handle }, + timeoutMs: 15_000 + }) + .then((response) => { + unwrapRuntimeRpcResult(response) + }) + .catch((error) => { + console.warn( + `[web-runtime-session] failed to ${action}:`, + error instanceof Error ? error.message : String(error) + ) + }) + return true +} diff --git a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts index 0a380a0625a..815ae36b523 100644 --- a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts @@ -140,7 +140,8 @@ describe('createUISlice hydratePersistedUI', () => { 'minimax', 'antigravity', 'grok', - 'cursor' + 'cursor', + 'zcode' ]) expect(setUI).toHaveBeenCalledWith({ statusBarItems: [ @@ -151,14 +152,16 @@ describe('createUISlice hydratePersistedUI', () => { 'minimax', 'antigravity', 'grok', - 'cursor' + 'cursor', + 'zcode' ], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, - _cursorStatusBarDefaultAdded: true + _cursorStatusBarDefaultAdded: true, + _zcodeStatusBarDefaultAdded: true }) }) @@ -175,7 +178,8 @@ describe('createUISlice hydratePersistedUI', () => { _minimaxStatusBarDefaultAdded: true, _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, - _cursorStatusBarDefaultAdded: true + _cursorStatusBarDefaultAdded: true, + _zcodeStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts index 47c770804c2..26b6071d64d 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-status-bar-items.ts @@ -8,6 +8,7 @@ const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' const DEFAULT_ON_CURSOR_STATUS_BAR_ITEM: StatusBarItem = 'cursor' +const DEFAULT_ON_ZCODE_STATUS_BAR_ITEM: StatusBarItem = 'zcode' export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { let items = migrateStatusBarItems(ui.statusBarItems) @@ -17,7 +18,8 @@ export function hydrateStatusBarItems(ui: PersistedUIState): StatusBarItem[] { ['_minimaxStatusBarDefaultAdded', DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM], ['_antigravityStatusBarDefaultAdded', DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM], ['_grokStatusBarDefaultAdded', DEFAULT_ON_GROK_STATUS_BAR_ITEM], - ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM] + ['_cursorStatusBarDefaultAdded', DEFAULT_ON_CURSOR_STATUS_BAR_ITEM], + ['_zcodeStatusBarDefaultAdded', DEFAULT_ON_ZCODE_STATUS_BAR_ITEM] ] as const for (const [flag, item] of defaults) { if (!ui[flag] && !items.includes(item)) { diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts index 039b1b5ca91..cb6dd89e026 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-row-worktree-ids.test.ts @@ -41,7 +41,7 @@ describe('buildWorktreeRenameState value-owned worktree rows', () => { const next = buildWorktreeRenameState( appState({ closedTerminalTabTombstonesByTabId: { - 'tab-1': { closedAt: 5, worktreeId: OLD, ackRevision: 3 }, + 'tab-1': { closedAt: 5, worktreeId: OLD, reason: 'user' }, 'tab-2': { closedAt: 6, worktreeId: OTHER } } }), @@ -49,7 +49,7 @@ describe('buildWorktreeRenameState value-owned worktree rows', () => { NEW ) expect(next.closedTerminalTabTombstonesByTabId).toEqual({ - 'tab-1': { closedAt: 5, worktreeId: NEW, ackRevision: 3 }, + 'tab-1': { closedAt: 5, worktreeId: NEW, reason: 'user' }, 'tab-2': { closedAt: 6, worktreeId: OTHER } }) }) diff --git a/src/renderer/src/store/terminals/terminal-surface-close-intent.ts b/src/renderer/src/store/terminals/terminal-surface-close-intent.ts index 0ee2d5aead4..25393aa8129 100644 --- a/src/renderer/src/store/terminals/terminal-surface-close-intent.ts +++ b/src/renderer/src/store/terminals/terminal-surface-close-intent.ts @@ -4,11 +4,12 @@ import type { TerminalSurfaceCloseTarget } from '../../../../shared/terminal-sur * cannot shrink it, so without this the close would ride on the PTY exit. */ export function commitTerminalSurfaceClose( worktreeId: string, - target: TerminalSurfaceCloseTarget + target: TerminalSurfaceCloseTarget, + reason?: 'user' | 'cleanup' ): void { // Why optional: an older preload can linger through an in-place renderer reload. void globalThis.window?.api?.session - ?.closeTerminalSurface?.({ worktreeId, target }) + ?.closeTerminalSurface?.({ worktreeId, target, ...(reason ? { reason } : {}) }) ?.catch((error: unknown) => { console.warn('[terminal-close] main did not commit the close', error) }) diff --git a/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts b/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts index 076de3babb6..61b51c05337 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts @@ -62,7 +62,8 @@ describe('closeTab close intent', () => { expect(mockApi.pty.kill).toHaveBeenCalledWith(SSH_PTY) expect(closeTerminalSurface).toHaveBeenCalledWith({ worktreeId: SSH_WORKTREE, - target: { kind: 'tab', tabId: 'ssh-tab' } + target: { kind: 'tab', tabId: 'ssh-tab' }, + reason: 'user' }) }) diff --git a/src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts b/src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts index 8a7d5039c8a..9d7820c7bd3 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close-tombstone.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { createTestStore, makeTab, makeWorktree, seedStore } from '../slices/store-test-helpers' import { createStoreCascadesMockApi } from '../slices/store-cascades-test-harness' +import { mergeDirectSshRemoteWorkspaceSession } from '@/hooks/remote-workspace-session-merge' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -43,38 +46,93 @@ function storeWithBothWorktrees(): ReturnType { return store } -describe('closeTab close tombstones', () => { +const closeTerminalSurface = vi.fn() +Object.assign(mockApi, { session: { closeTerminalSurface } }) + +describe('closeTab close-record mirror', () => { beforeEach(() => { vi.clearAllMocks() + closeTerminalSurface.mockResolvedValue(undefined) mockApi.worktrees.updateMeta.mockResolvedValue({}) }) - it('records the closed tab against the worktree it was closed in', () => { + // Why every workspace kind: main records every close it is told about, and the mirror is what + // the seeding predicate reads before the next launch hydrates main's copy. + it.each([ + ['remote-tab', REMOTE_WORKTREE], + ['local-tab', LOCAL_WORKTREE] + ])('mirrors the record main is told to write for %s', (tabId, worktreeId) => { const store = storeWithBothWorktrees() - store.getState().closeTab('remote-tab') + store.getState().closeTab(tabId) - expect(store.getState().closedTerminalTabTombstonesByTabId['remote-tab']).toEqual({ + expect(store.getState().closedTerminalTabTombstonesByTabId[tabId]).toEqual({ closedAt: expect.any(Number), - worktreeId: REMOTE_WORKTREE + worktreeId, + reason: 'user' + }) + expect(closeTerminalSurface).toHaveBeenCalledWith({ + worktreeId, + target: { kind: 'tab', tabId }, + reason: 'user' }) }) - // A tombstone outlives the host's own record, so the only claim it may ever make is "the user - // closed this". Neither of these closes is that claim. - it.each([['pty-exit'], ['cleanup']] as const)('records nothing for a %s close', (reason) => { + it('mirrors a cleanup close with its reason', () => { const store = storeWithBothWorktrees() - store.getState().closeTab('remote-tab', { reason }) + store.getState().closeTab('remote-tab', { reason: 'cleanup' }) - expect(store.getState().closedTerminalTabTombstonesByTabId['remote-tab']).toBeUndefined() + expect(store.getState().closedTerminalTabTombstonesByTabId['remote-tab']?.reason).toBe( + 'cleanup' + ) }) - it('records nothing for a definitively local worktree, which no pull merge ever reads', () => { + // Main alone closes for a process exit; a paired host owns its own records. + it.each([ + ['a pty-exit close', { reason: 'pty-exit' as const }], + ['a host-owned close', { remoteCloseOwnedByHost: true }] + ])('mirrors nothing for %s', (_label, opts) => { const store = storeWithBothWorktrees() - store.getState().closeTab('local-tab') + store.getState().closeTab('remote-tab', opts) expect(store.getState().closedTerminalTabTombstonesByTabId).toEqual({}) + expect(closeTerminalSurface).not.toHaveBeenCalled() + }) + + // The race the mirror exists for: a host pull lands after closeTab and before main has answered + // the close intent (which never resolves here). The host still lists the tab. + it('keeps a closed SSH tab closed through a pull that lands before main answers', () => { + closeTerminalSurface.mockReturnValue(new Promise(() => {})) + const store = storeWithBothWorktrees() + const hostStillListing = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [REMOTE_WORKTREE]: [makeTab({ id: 'remote-tab', worktreeId: REMOTE_WORKTREE })] + } + } + const applyPull = (): void => { + const state = store.getState() + const merged = mergeDirectSshRemoteWorkspaceSession( + buildWorkspaceSessionPayload(state), + hostStillListing, + new Set([REMOTE_WORKTREE]), + state.tabsByWorktree, + new Set(), + undefined, + state.closedTerminalTabTombstonesByTabId + ) + const replaceWorkspaceKeys = [REMOTE_WORKTREE] + store.getState().hydrateWorkspaceSession(merged, { replaceWorkspaceKeys }) + store.getState().hydrateTabsSession(merged, { replaceWorkspaceKeys }) + } + + store.getState().closeTab('remote-tab') + applyPull() + // A second pull: once re-added, a live local tab would override its record for good. + applyPull() + + expect(store.getState().tabsByWorktree[REMOTE_WORKTREE]?.map((tab) => tab.id) ?? []).toEqual([]) }) }) diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 529b19ded0b..8227521d2ec 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -1,6 +1,4 @@ -import { recordClosedTerminalTabTombstone } from '../../../../shared/closed-terminal-tab-tombstones' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' import { sweepRetiredTerminalTabState } from '../slices/retired-terminal-tab-state-sweep' import { getRecentlyClosedTabPosition, @@ -27,7 +25,9 @@ export function createTerminalTabCloseActions( return { closeTab: (tabId, opts) => { const closeReason = opts?.reason ?? 'user' - const retiresSession = closeReason === 'user' || closeReason === 'cleanup' + // Why narrowed separately: main alone records a close for a process exit. + const intentReason = closeReason === 'pty-exit' ? null : closeReason + const retiresSession = intentReason !== null const retirementPlan = opts?.precomputedRetirementPlan?.tabId === tabId ? opts.precomputedRetirementPlan @@ -69,22 +69,19 @@ export function createTerminalTabCloseActions( next[wId] = after } } - // Why `user` and not retiresSession: a tombstone outlives the host's own record, so the only - // thing it may ever say is "the user closed this". A pty-exit close is the process ending, - // and a `cleanup` close retires a tab the app itself created — neither is that claim. - // Why a non-local worktree only: the tombstone is read solely by the direct-SSH pull merge, - // and a definitively local tab would just consume the map's cap. An unresolved repo - // (undefined, not null) still records — losing the tombstone reinstates the resurrection. + // Why mirrored here and never persisted: main records the close through the intent below, + // and the direct-SSH pull merge reads this synchronously, so a pull landing before main + // answered would otherwise re-add the tab. Goes away when that merge moves to main. const nextClosedTombstones = - closeReason === 'user' && - closedWorktreeId && - getConnectionIdFromState(s, closedWorktreeId) !== null - ? recordClosedTerminalTabTombstone( - s.closedTerminalTabTombstonesByTabId, - tabId, - closedWorktreeId, - Date.now() - ) + intentReason && closedWorktreeId && opts?.remoteCloseOwnedByHost !== true + ? { + ...s.closedTerminalTabTombstonesByTabId, + [tabId]: { + closedAt: Date.now(), + worktreeId: closedWorktreeId, + reason: intentReason + } + } : s.closedTerminalTabTombstonesByTabId // Why: only explicit user closes feed the Cmd+Shift+T reopen stack; cleanup/PTY-exit closes must not pollute undo history. const closedPosition = @@ -250,8 +247,8 @@ export function createTerminalTabCloseActions( : {}) } }) - if (retiresSession && closingWorktreeId && opts?.remoteCloseOwnedByHost !== true) { - commitTerminalSurfaceClose(closingWorktreeId, { kind: 'tab', tabId }) + if (intentReason && closingWorktreeId && opts?.remoteCloseOwnedByHost !== true) { + commitTerminalSurfaceClose(closingWorktreeId, { kind: 'tab', tabId }, intentReason) } // Why shared with the paired snapshot apply: every path that removes a tab owes it the same sweep, and a second copy of the list is how one path silently misses a new entry. sweepRetiredTerminalTabState(get(), tabId, closingWorktreeId) diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index 6f2e3d47e75..ef53641d0dc 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -198,7 +198,7 @@ export function targetScopedWorkspaceHydrationPatch( ), ...state.defaultTerminalTabsAppliedByWorktreeId }, - // Why passed through whole: hydration already unioned it with live store state, and the map is + // Why passed through whole: hydration already fell back to live store state, and the map is // keyed by tab id rather than by workspace key so replaceHydratedRecordKeys has nothing to match. closedTerminalTabTombstonesByTabId: hydrated.closedTerminalTabTombstonesByTabId, automaticAgentResumeClaimsByTabId: replaceHydratedRecordKeys( diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index db278e05d92..9cfe4e687e8 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -191,11 +191,10 @@ export function createWorkspaceTerminalHydrationActions( ...session.defaultTerminalTabsAppliedByWorktreeId, ...s.defaultTerminalTabsAppliedByWorktreeId }, - // Why replace and not union: both callers hand over a map they derived from this store - // synchronously (the pull merge) or from disk before the store had one (startup), so there - // is no local tombstone to lose — and a union would resurrect the ones the merge just - // retired on the host's acknowledgement, which is the whole bound on this map. - closedTerminalTabTombstonesByTabId: session.closedTerminalTabTombstonesByTabId ?? {}, + // Why keep the store's when absent: only main's session carries close records, and the + // pull merge's session does not, so re-hydrating from it must not clear the mirror. + closedTerminalTabTombstonesByTabId: + session.closedTerminalTabTombstonesByTabId ?? s.closedTerminalTabTombstonesByTabId, automaticAgentResumeClaimsByTabId: {}, sleepingAgentSessionsByPaneKey, pendingReconnectWorktreeIds, diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index 535c3454431..be6dc77cd7d 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -17,6 +17,8 @@ export function createPtyApi(): NonNullable['pty']> { signal: () => {}, // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. clearBuffer: () => {}, + // Likewise terminal.resetInputModes. + resetInputModes: () => {}, kill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, @@ -84,6 +86,7 @@ export function createPtyApi(): NonNullable['pty']> { onSpawned: () => noopUnsubscribe, onSerializeBufferRequest: () => noopUnsubscribe, onClearBufferRequest: () => noopUnsubscribe, + onResetInputModesRequest: () => noopUnsubscribe, sendSerializedBuffer: () => {}, declarePendingPaneSerializer: () => Promise.resolve(0), settlePaneSerializer: () => Promise.resolve(), diff --git a/src/shared/__fixtures__/qoder-no-account-hooks.jsonl b/src/shared/__fixtures__/qoder-no-account-hooks.jsonl new file mode 100644 index 00000000000..0508c506368 --- /dev/null +++ b/src/shared/__fixtures__/qoder-no-account-hooks.jsonl @@ -0,0 +1,6 @@ +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionStart","permission_mode":"default","source":"startup"} +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"UserPromptSubmit","permission_mode":"default","prompt":"Reply with hello.","request_set_id":"00000000-0000-4000-8000-000000000000"} +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"Notification","permission_mode":"default","notification_type":"idle_prompt","message":"Qoder CLI finished responding and is awaiting input.","details":{"streamingState":"idle"}} +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionEnd","permission_mode":"default","reason":"other"} +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionStart","permission_mode":"default","source":"resume"} +{"session_id":"00000000-0000-4000-8000-000000000000","transcript_path":"/private/tmp/orca-qoder-probe/config/projects/-private-tmp-orca-qoder-probe-workspace/00000000-0000-4000-8000-000000000000.jsonl","cwd":"/private/tmp/orca-qoder-probe/workspace","hook_event_name":"SessionEnd","permission_mode":"default","reason":"other"} diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index 3ce26e949c3..f3f67535963 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -1,4 +1,6 @@ +import { isQoderHeadlessCommand } from './qoder-headless-command' import { isAnteHeadlessOneShotCommand } from './ante-headless-command' +import { isDshNonInteractiveCommand } from './dsh-launch-command' import { isMuseHeadlessOneShotCommand } from './muse-headless-command' import { isZCodeHeadlessOneShotCommand } from './zcode-headless-command' import { isPrimeAgentHeadlessOneShotCommand } from './prime-agent-headless-command' @@ -8,16 +10,20 @@ import type { TuiAgent } from './tui-agent' // Why: a table (not an if-chain) so adding an agent is one entry; Claude and Trae share // the same `--print` one-shot contract, Ante's `--prompt` form, Prime Agent's // `--mode` forms, Muse's `exec` subcommand, and ZCode's `--prompt`/`--target` forms need -// their own matchers. +// their own matchers. DSH's entry is wider than a one-shot: `dsh` also boots a web server +// and JSON-RPC stdio profiles, and none of those can answer a prompt in the pane either, +// which is what this table gates. const HEADLESS_ONE_SHOT_MATCHERS: Partial< Record boolean> > = { + qoder: isQoderHeadlessCommand, claude: isPrintModeHeadlessOneShotCommand, trae: isPrintModeHeadlessOneShotCommand, 'prime-agent': isPrimeAgentHeadlessOneShotCommand, ante: isAnteHeadlessOneShotCommand, muse: isMuseHeadlessOneShotCommand, - zcode: isZCodeHeadlessOneShotCommand + zcode: isZCodeHeadlessOneShotCommand, + dsh: isDshNonInteractiveCommand } export function isHeadlessOneShotAgentCommand(agent: TuiAgent, tokens: readonly string[]): boolean { diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index d3abd287e89..067e4552212 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -1,3 +1,4 @@ +import { normalizeQoderEvent } from './providers/qoder-events' import type { ParsedAgentStatusPayload } from '../agent-status-types' import type { AgentHookSource } from '../agent-hook-relay' import { readLastCommandCodeUserPromptEntryFromTranscript } from './command-code-transcript' @@ -23,6 +24,7 @@ import { normalizeHermesEvent } from './providers/hermes-events' import { normalizeDevinEvent } from './providers/devin-events' import { normalizeKimiEvent } from './providers/kimi-events' import { normalizeMuseEvent } from './providers/muse-events' +import { normalizeDshEvent } from './providers/dsh-events' import { normalizeZCodeEvent } from './providers/zcode-events' export type ProviderDispatchResult = { @@ -148,12 +150,18 @@ export function normalizeProviderEvent(input: { case 'devin': payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayload) break + case 'qoder': + payload = normalizeQoderEvent(state, eventName, promptText, paneKey, hookPayload) + break case 'kimi': payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayload) break case 'muse': payload = normalizeMuseEvent(state, eventName, promptText, paneKey, hookPayload) break + case 'dsh': + payload = normalizeDshEvent(state, eventName, promptText, paneKey, hookPayload) + break case 'zcode': payload = normalizeZCodeEvent(state, eventName, promptText, paneKey, hookPayload) break diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index f9dc1e6d5d7..40e59a1d820 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -25,6 +25,7 @@ import { extractHermesToolFields } from './providers/hermes-tool-fields' export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false. switch (source) { + case 'qoder': case 'claude': // Why: SessionStart lands an idle row (STA-3386) and must also drop stale // tool/prompt caches left by the pane's previous session. @@ -35,6 +36,10 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo case 'muse': // Muse uses Claude-compatible lifecycle events. return eventName === 'UserPromptSubmit' + case 'dsh': + // Why: DSH's Claude-Code hook bridge fires SessionStart once per session before the + // first turn, which is the point stale tool/prompt caches from a reused pane must go. + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' case 'zcode': // Why: matches Codex/Claude — SessionStart lands an idle boundary row and drops stale // tool/prompt caches, while UserPromptSubmit is the actual turn boundary. @@ -134,6 +139,7 @@ export function extractToolFields( ): ToolSnapshot { // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently routing through OpenCode's extractor. switch (source) { + case 'qoder': case 'claude': // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. // falls through @@ -141,6 +147,9 @@ export function extractToolFields( // Muse uses Claude-compatible tool fields. // falls through case 'muse': + // DSH's own Claude-Code hook bridge emits Claude's tool_name/tool_input verbatim. + // falls through + case 'dsh': // Why: ZCode's hook runner writes Claude's `tool_name`/`tool_input`/`tool_response` aliases. // falls through case 'zcode': diff --git a/src/shared/agent-hook-listener/providers/dsh-events.test.ts b/src/shared/agent-hook-listener/providers/dsh-events.test.ts new file mode 100644 index 00000000000..b552018845d --- /dev/null +++ b/src/shared/agent-hook-listener/providers/dsh-events.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createHookListenerState, type HookListenerState } from '../listener-state' +import { normalizeAndAccept } from '../../agent-hook-listener-test-harness' + +// Payload shape copied from a live run of @deepseek-ai/dsh-hooks-claude-code 0.1.5-rc.3 +// mounted through $DSH_HOME/cordis.patch.yml: `session_id`, an always-empty +// `transcript_path`, `cwd`, `hook_event_name`, plus the per-event fields. +const SESSION_ID = '9478e2d8-29bc-4009-ab32-657efa2bd763' +const CHILD_SESSION_ID = 'b1c4fb80-4f12-4a6f-9a68-7c0d5f0f5a12' + +const base = { transcript_path: '', cwd: '/tmp/ws', session_id: SESSION_ID } + +function event(name: string, extra: Record = {}): Record { + return { ...base, hook_event_name: name, ...extra } +} + +describe('normalizeDshEvent', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + it('reports a prompt as working and a stop as done', () => { + const submitted = normalizeAndAccept( + state, + 'dsh', + event('UserPromptSubmit', { prompt: 'fix the flaky test' }) + ) + expect(submitted?.payload.state).toBe('working') + expect(submitted?.payload.agentType).toBe('dsh') + expect(submitted?.payload.prompt).toBe('fix the flaky test') + + const stopped = normalizeAndAccept(state, 'dsh', event('Stop', { stop_hook_active: false })) + expect(stopped?.payload.state).toBe('done') + // The prompt is carried across the turn so a finished row still names the work. + expect(stopped?.payload.prompt).toBe('fix the flaky test') + }) + + it('lands an idle row on SessionStart', () => { + const started = normalizeAndAccept(state, 'dsh', event('SessionStart', { source: 'startup' })) + expect(started?.payload.state).toBe('done') + expect(started?.payload.agentType).toBe('dsh') + }) + + it('treats an ordinary tool as working and surfaces its name', () => { + normalizeAndAccept(state, 'dsh', event('UserPromptSubmit', { prompt: 'read the readme' })) + const pre = normalizeAndAccept( + state, + 'dsh', + event('PreToolUse', { + tool_name: 'read', + tool_input: { path: 'README.md' }, + tool_use_id: 'call-1' + }) + ) + expect(pre?.payload.state).toBe('working') + expect(pre?.payload.toolName).toBe('read') + }) + + it('reports ask_user_question as waiting and its answer as working again', () => { + normalizeAndAccept(state, 'dsh', event('UserPromptSubmit', { prompt: 'pick a colour' })) + const asked = normalizeAndAccept( + state, + 'dsh', + event('PreToolUse', { + tool_name: 'ask_user_question', + tool_input: { + questions: [{ header: 'Colour', question: 'Which one?', options: [{ label: 'Blue' }] }] + }, + tool_use_id: 'call-2' + }) + ) + // Why this matters: DSH's bridge has no Notification/PermissionRequest event, so the + // ask-the-user tool is the only structured signal that a pane needs the user. + expect(asked?.payload.state).toBe('waiting') + expect(asked?.payload.interactivePrompt).toContain('Which one?') + + const answered = normalizeAndAccept( + state, + 'dsh', + event('PostToolUse', { + tool_name: 'ask_user_question', + tool_use_id: 'call-2', + tool_response: 'Blue' + }) + ) + expect(answered?.payload.state).toBe('working') + }) + + it('ignores subagent lifecycle events', () => { + normalizeAndAccept(state, 'dsh', event('UserPromptSubmit', { prompt: 'delegate this' })) + const done = normalizeAndAccept(state, 'dsh', event('Stop', {})) + expect(done?.payload.state).toBe('done') + + // The bridge stamps the CHILD's session id and a constant `general-purpose` agent_type + // on both subagent events, so a child that outlives the lead's Stop would otherwise + // flip the finished pane back to working (the Muse regression in #22216). + for (const name of ['SubagentStart', 'SubagentStop']) { + expect( + normalizeAndAccept(state, 'dsh', { + ...base, + session_id: CHILD_SESSION_ID, + hook_event_name: name, + agent_id: 'agent-7', + agent_type: 'general-purpose' + }) + ).toBeNull() + } + }) + + it('ignores events the bridge cannot send', () => { + // Notification and PermissionRequest are Claude-only; if one ever arrives it is not + // from this bridge, and guessing a state from it would be unfounded. + expect( + normalizeAndAccept(state, 'dsh', event('Notification', { message: 'waiting' })) + ).toBeNull() + expect( + normalizeAndAccept(state, 'dsh', event('PermissionRequest', { tool_name: 'bash' })) + ).toBeNull() + }) +}) diff --git a/src/shared/agent-hook-listener/providers/dsh-events.ts b/src/shared/agent-hook-listener/providers/dsh-events.ts new file mode 100644 index 00000000000..670cba55c17 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/dsh-events.ts @@ -0,0 +1,84 @@ +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { + resolvePrompt, + resolveToolState, + shouldIgnoreCompactContinuationUserPromptSubmit +} from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +/** + * DeepSeek Harness reaches Orca through its own `@deepseek-ai/dsh-hooks-claude-code` + * bridge, so the payloads are Claude-shaped (`session_id`, `tool_name`, `tool_input`, + * `tool_use_id`, `prompt`) and the event names are a strict subset of Claude's: + * SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart and + * SubagentStop. There is no Notification and no PermissionRequest. + * + * That missing pair is why the waiting state is read from the tool instead: DSH asks the + * user through `ask_user_question` (`@deepseek-ai/dsh-tool-ask-user`), whose PreToolUse + * fires while the question card owns the screen and whose PostToolUse only lands once the + * user answers. An approval pause has no hook of its own and reads as `working`, which is + * the honest answer — the harness is mid-tool, not idle. + */ +export function normalizeDshEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + + // Why: the bridge stamps the *child's* session id on both subagent events and labels + // every child `general-purpose`, so a child turn cannot be told from the lead's. Muse + // (#22216) showed what that costs: child hooks flip a finished pane back to working. + if (eventName === 'SubagentStart' || eventName === 'SubagentStop') { + return null + } + + const toolName = readString(hookPayload, 'tool_name') + + let stateName: 'working' | 'waiting' | 'done' + switch (eventName) { + case 'UserPromptSubmit': + case 'PostToolUse': + stateName = 'working' + break + case 'PreToolUse': + stateName = isAskUserQuestionTool(toolName) ? 'waiting' : 'working' + break + case 'SessionStart': + case 'Stop': + stateName = 'done' + break + default: + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('dsh', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('dsh', eventName) } + ) + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('dsh', eventName) + }), + agentType: 'dsh', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput + }) +} diff --git a/src/shared/agent-hook-listener/providers/qoder-events.ts b/src/shared/agent-hook-listener/providers/qoder-events.ts new file mode 100644 index 00000000000..bdf2a622dbe --- /dev/null +++ b/src/shared/agent-hook-listener/providers/qoder-events.ts @@ -0,0 +1,87 @@ +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { + resolvePrompt, + resolveToolState, + shouldIgnoreCompactContinuationUserPromptSubmit +} from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +function readQoderState( + eventName: unknown, + payload: Record +): 'working' | 'waiting' | 'done' | null { + switch (eventName) { + case 'SessionStart': + // Compaction restarts the session during an active turn. + return ['startup', 'resume', 'clear', 'new'].includes(String(payload.source)) ? 'done' : null + case 'UserPromptSubmit': + case 'PostToolUse': + case 'PostToolUseFailure': + return 'working' + case 'PreToolUse': + return isAskUserQuestionTool(readString(payload, 'tool_name')) ? 'waiting' : 'working' + case 'PermissionRequest': + return 'waiting' + case 'Stop': + case 'StopFailure': + case 'SessionEnd': + return 'done' + case 'PostCompact': + return payload.trigger === 'manual' ? 'done' : null + case 'Notification': + if ( + payload.notification_type === 'permission_prompt' || + payload.notification_type === 'elicitation_dialog' + ) { + return 'waiting' + } + return payload.notification_type === 'idle_prompt' ? 'done' : null + default: + return null + } +} + +export function normalizeQoderEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + const stateName = readQoderState(eventName, hookPayload) + if (!stateName) { + return null + } + const resetOnNewTurn = isNewTurnEvent('qoder', eventName) + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('qoder', eventName, hookPayload), + { resetOnNewTurn } + ) + return normalizeAgentStatusPayload({ + state: stateName, + agentType: 'qoder', + prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn }), + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + sessionBoundary: eventName === 'SessionStart' || eventName === 'PostCompact' ? true : undefined, + interrupted: eventName === 'Stop' && hookPayload.is_interrupt === true ? true : undefined, + mainAgent: + eventName === 'StopFailure' + ? { state: 'done', outcome: 'failure', stateStartedAt: Date.now() } + : undefined + }) +} diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index e84313b0c26..2b7f0147e60 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -5,6 +5,7 @@ import type { AgentHookSource } from '../agent-hook-relay' export const HOOK_SOURCE_BY_PATHNAME: Readonly> = Object.freeze({ '/hook/claude': 'claude', '/hook/codex': 'codex', + '/hook/qoder': 'qoder', '/hook/gemini': 'gemini', '/hook/antigravity': 'antigravity', '/hook/amp': 'amp', @@ -23,7 +24,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/devin': 'devin', '/hook/kimi': 'kimi', '/hook/muse': 'muse', - '/hook/zcode': 'zcode' + '/hook/zcode': 'zcode', + '/hook/dsh': 'dsh' }) export function resolveHookSource(pathname: string): AgentHookSource | null { diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index cc5d21ba8f7..7e0d452a66b 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -37,6 +37,7 @@ import type { AgentHookTarget } from './agent-hook-types' const AGENT_HOOK_SOURCES = [ 'claude', 'codex', + 'qoder', 'gemini', 'antigravity', 'amp', @@ -55,7 +56,8 @@ const AGENT_HOOK_SOURCES = [ 'devin', 'kimi', 'muse', - 'zcode' + 'zcode', + 'dsh' ] as const export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number] diff --git a/src/shared/agent-hook-scrub-safe-env.test.ts b/src/shared/agent-hook-scrub-safe-env.test.ts new file mode 100644 index 00000000000..42368b03b19 --- /dev/null +++ b/src/shared/agent-hook-scrub-safe-env.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + applyScrubSafeAgentEnvAliases, + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from './agent-hook-scrub-safe-env' + +/** The rule DeepSeek Harness's shell executor applies, measured against dsh 0.1.5-rc.1. */ +function isScrubbedName(name: string): boolean { + return /KEY|TOKEN|SECRET|PASSWORD/.test(name) +} + +describe('applyScrubSafeAgentEnvAliases', () => { + it('mirrors pane identity onto names a credential scrub keeps', () => { + const env: Record = { + ORCA_PANE_KEY: 'tab-1:11111111-1111-4111-8111-111111111111', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-abc' + } + applyScrubSafeAgentEnvAliases(env) + expect(env[ORCA_SCRUB_SAFE_PANE_ENV]).toBe(env.ORCA_PANE_KEY) + expect(env[ORCA_SCRUB_SAFE_LAUNCH_ENV]).toBe(env.ORCA_AGENT_LAUNCH_TOKEN) + }) + + it('picks alias names the scrub does not take', () => { + // The whole point: if an alias were itself scrubbed it would carry nothing. + expect(isScrubbedName(ORCA_SCRUB_SAFE_PANE_ENV)).toBe(false) + expect(isScrubbedName(ORCA_SCRUB_SAFE_LAUNCH_ENV)).toBe(false) + expect(isScrubbedName('ORCA_PANE_KEY')).toBe(true) + expect(isScrubbedName('ORCA_AGENT_LAUNCH_TOKEN')).toBe(true) + }) + + it('leaves the canonical names untouched', () => { + const env: Record = { ORCA_PANE_KEY: 'pane', ORCA_AGENT_LAUNCH_TOKEN: 'tok' } + applyScrubSafeAgentEnvAliases(env) + expect(env.ORCA_PANE_KEY).toBe('pane') + expect(env.ORCA_AGENT_LAUNCH_TOKEN).toBe('tok') + }) + + it('drops a stale alias when its source is gone', () => { + // Why this matters: an alias inherited from a parent pane would attribute this pane's + // hooks to the wrong row — worse than reporting no status at all. + const env: Record = { + [ORCA_SCRUB_SAFE_PANE_ENV]: 'inherited-pane', + [ORCA_SCRUB_SAFE_LAUNCH_ENV]: 'inherited-launch' + } + applyScrubSafeAgentEnvAliases(env) + expect(env[ORCA_SCRUB_SAFE_PANE_ENV]).toBeUndefined() + expect(env[ORCA_SCRUB_SAFE_LAUNCH_ENV]).toBeUndefined() + }) + + it('treats an empty source as absent', () => { + const env: Record = { + ORCA_PANE_KEY: '', + [ORCA_SCRUB_SAFE_PANE_ENV]: 'stale' + } + applyScrubSafeAgentEnvAliases(env) + expect(env[ORCA_SCRUB_SAFE_PANE_ENV]).toBeUndefined() + }) +}) diff --git a/src/shared/agent-hook-scrub-safe-env.ts b/src/shared/agent-hook-scrub-safe-env.ts new file mode 100644 index 00000000000..e83895e2a30 --- /dev/null +++ b/src/shared/agent-hook-scrub-safe-env.ts @@ -0,0 +1,49 @@ +/** + * Scrub-safe aliases for the two pane-identity env vars whose NAMES some agent harnesses + * refuse to pass through to a hook process. + * + * DeepSeek Harness runs command hooks through its own shell executor, which drops every + * variable whose name contains `KEY`, `TOKEN`, `SECRET` or `PASSWORD` before the hook + * process starts. That is a reasonable credential-scrub default, and it is not something + * Orca can turn off — but it takes `ORCA_PANE_KEY` and `ORCA_AGENT_LAUNCH_TOKEN` with it, + * which are the two values a status hook needs to say *which pane* it is reporting for. + * Without them the hook has nothing to attribute to and exits without posting, so the pane + * reads as an agent Orca never hears from. + * + * Verified against `@deepseek-ai/dsh` 0.1.5-rc.1 by running a probe hook that printed its + * own environment: `PROBE_PLAIN`, `PROBE_AUTH`, `PROBE_CREDENTIAL`, `PROBE_HOOK_PORT` and + * `PROBE_PANE_ID` arrive; `PROBE_KEY`, `PROBE_TOKEN`, `PROBE_SECRET`, `PROBE_PASSWORD` and + * `PROBE_PANE_KEY` do not. + * + * These are aliases, never replacements: the canonical names stay exactly as they are, so + * every existing agent, script and test keeps reading what it reads today. + */ + +/** Alias for `ORCA_PANE_KEY`. */ +export const ORCA_SCRUB_SAFE_PANE_ENV = 'ORCA_AGENT_PANE' as const + +/** Alias for `ORCA_AGENT_LAUNCH_TOKEN`. */ +export const ORCA_SCRUB_SAFE_LAUNCH_ENV = 'ORCA_AGENT_LAUNCH' as const + +const ALIASES: readonly (readonly [source: string, alias: string])[] = [ + ['ORCA_PANE_KEY', ORCA_SCRUB_SAFE_PANE_ENV], + ['ORCA_AGENT_LAUNCH_TOKEN', ORCA_SCRUB_SAFE_LAUNCH_ENV] +] + +/** + * Mirror pane identity onto its scrub-safe aliases, in place. + * + * An alias is written only when its source is present, and dropped when the source is + * absent — a stale alias inherited from a parent pane would attribute this pane's hooks to + * the wrong row, which is worse than no status at all. + */ +export function applyScrubSafeAgentEnvAliases(env: Record): void { + for (const [source, alias] of ALIASES) { + const value = env[source] + if (value === undefined || value === '') { + delete env[alias] + continue + } + env[alias] = value + } +} diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index a006c2cc37d..cc83527fc09 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -7,6 +7,7 @@ export const AGENT_HOOK_TARGETS = [ 'claude', 'openclaude', 'codex', + 'qoder', 'gemini', 'antigravity', 'amp', @@ -19,7 +20,8 @@ export const AGENT_HOOK_TARGETS = [ 'devin', 'kimi', 'muse', - 'zcode' + 'zcode', + 'dsh' ] as const export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number] diff --git a/src/shared/agent-icons/dsh.png b/src/shared/agent-icons/dsh.png new file mode 100644 index 00000000000..b5835f8f61f Binary files /dev/null and b/src/shared/agent-icons/dsh.png differ diff --git a/src/shared/agent-icons/freebuff.png b/src/shared/agent-icons/freebuff.png new file mode 100644 index 00000000000..acb0e0aadde Binary files /dev/null and b/src/shared/agent-icons/freebuff.png differ diff --git a/src/shared/agent-icons/qoder.png b/src/shared/agent-icons/qoder.png new file mode 100644 index 00000000000..42c952ed33a Binary files /dev/null and b/src/shared/agent-icons/qoder.png differ diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 06fa51ab76c..375bac1bd2e 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -25,6 +25,7 @@ const TUI_AGENT_KIND_BY_AGENT = { pi: 'pi', omp: 'omp', 'prime-agent': 'prime-agent', + qoder: 'qoder', gemini: 'gemini', antigravity: 'antigravity', aider: 'aider', @@ -36,6 +37,7 @@ const TUI_AGENT_KIND_BY_AGENT = { aug: 'aug', cline: 'cline', codebuff: 'codebuff', + freebuff: 'freebuff', 'command-code': 'command-code', continue: 'continue', cursor: 'cursor', @@ -52,7 +54,8 @@ const TUI_AGENT_KIND_BY_AGENT = { ante: 'ante', trae: 'trae', muse: 'muse', - zcode: 'zcode' + zcode: 'zcode', + dsh: 'dsh' } satisfies Record // Why: `satisfies Record` makes the lookup exhaustive at compile diff --git a/src/shared/agent-node-entrypoint-identities.ts b/src/shared/agent-node-entrypoint-identities.ts index e71118312d0..fb4327c076f 100644 --- a/src/shared/agent-node-entrypoint-identities.ts +++ b/src/shared/agent-node-entrypoint-identities.ts @@ -26,5 +26,13 @@ export const EXACT_NODE_ENTRYPOINT_IDENTITIES: readonly { pattern: /(?:^|\/)node_modules\/prime-agent\/dist\/bundle\/cli\.js$/, agent: 'prime-agent', processName: 'prime-agent' + }, + // Why: DSH's npm shim launches a generic `lib/bin.js`, and the launcher re-execs it + // directly on Windows, so only the package path identifies it. The profile still decides + // whether the pane is the agent — `dsh web` resolves here too and is filtered after. + { + pattern: /(?:^|\/)node_modules\/@deepseek-ai\/dsh\/lib\/bin\.js$/, + agent: 'dsh', + processName: 'dsh' } ] diff --git a/src/shared/agent-node-package-entrypoints.ts b/src/shared/agent-node-package-entrypoints.ts new file mode 100644 index 00000000000..905d41f95f0 --- /dev/null +++ b/src/shared/agent-node-package-entrypoints.ts @@ -0,0 +1,16 @@ +// Node CLIs whose shim launches a script named after the agent, but generic enough that +// the basename alone is not identity (`dsh-tui.js` could be anyone's file). The package +// directory in the resolved path is what makes the match authoritative. +// +// Split out of agent-process-recognition.ts so the recognizer keeps only its logic and a +// new agent's install path is a data edit, as in agent-node-entrypoint-identities.ts. +export const NODE_PACKAGE_SCRIPT_ENTRYPOINTS: Record = { + codex: ['node_modules/@openai/codex/'], + gemini: ['node_modules/@google/gemini-cli/'], + // Why: ZCode's npm bin is `dist/zcode.cjs`, so a package install runs as `node …zcode.cjs` + // and never shows `zcode` as the foreground name (a SEA build still matches by name). + zcode: ['node_modules/@zcode/cli/'], + // Why: the `dsh-tui` launcher is installed twice — once on PATH and once inside the + // profile it bootstraps — and the PATH copy re-execs the profile copy by absolute path. + 'dsh-tui': ['node_modules/@deepseek-harness-tui/dsh-tui/'] +} diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index a0f0faa68e7..a8262eb4760 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -1,5 +1,6 @@ import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from './tui-agent-config' import { EXACT_NODE_ENTRYPOINT_IDENTITIES } from './agent-node-entrypoint-identities' +import { NODE_PACKAGE_SCRIPT_ENTRYPOINTS } from './agent-node-package-entrypoints' import type { AgentType } from './agent-status-types' import type { TuiAgent } from './tui-agent' import { filterHeadlessOneShotAgentCommand } from './agent-headless-command' @@ -35,13 +36,6 @@ function normalizeProcessName( } const FOREGROUND_AGENT_WRAPPER_PROCESS_NAMES = new Set(['node', 'python', 'python3']) -const NODE_PACKAGE_SCRIPT_ENTRYPOINTS: Record = { - codex: ['node_modules/@openai/codex/'], - gemini: ['node_modules/@google/gemini-cli/'], - // Why: ZCode's npm bin is `dist/zcode.cjs`, so a package install runs as `node …zcode.cjs` - // and never shows `zcode` as the foreground name (a SEA build still matches by name). - zcode: ['node_modules/@zcode/cli/'] -} const PYTHON_SCRIPT_ENTRYPOINT_DIRECTORIES = ['/bin/', '/scripts/', '/site-packages/'] const PROCESS_TO_AGENT = new Map() @@ -79,6 +73,10 @@ function agentForNormalizedProcess(normalized: string): TuiAgent | undefined { if (normalized.startsWith('codex-')) { return PROCESS_TO_AGENT.get('codex') } + // Qoder's launcher resolves to a versioned native binary. + if (/^qodercli-\d/.test(normalized)) { + return PROCESS_TO_AGENT.get('qodercli') + } if (normalized.startsWith('grok-')) { return PROCESS_TO_AGENT.get('grok') } @@ -164,6 +162,7 @@ export function isExpectedAgentProcess( return ( normalizedProcess === normalizedExpected || normalizedProcess.startsWith(`${normalizedExpected}.`) || + (normalizedExpected === 'qodercli' && /^qodercli-\d/.test(normalizedProcess)) || (normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-')) ) } diff --git a/src/shared/agent-session-conversation-command.ts b/src/shared/agent-session-conversation-command.ts index e1c038de934..8b5ecb6ccf0 100644 --- a/src/shared/agent-session-conversation-command.ts +++ b/src/shared/agent-session-conversation-command.ts @@ -1,11 +1,20 @@ +import type { AgentSessionFailureFact } from './agent-session-failure' +import type { AgentSessionFailureSentence } from './agent-session-failure-words' + export type AgentSessionConversationCommand = 'clear' | 'compact' export type AgentSessionConversationCommandResult = { command: AgentSessionConversationCommand state: 'completed' | 'unknown' replacementSessionId?: string - error?: string -} +} & + /** `error`: a sentence for a person; released clients print it as it is. */ + ( + | { error?: string; failure?: undefined } + /** What failed, typed, beside its sentence, both from `agentSessionFailureWords`; `failure` is + * absent from older hosts. */ + | { error: AgentSessionFailureSentence; failure: AgentSessionFailureFact } + ) export type AgentSessionConversationCommandRecord = AgentSessionConversationCommandResult & { runtimeFence?: number diff --git a/src/shared/agent-session-conversation-outline.ts b/src/shared/agent-session-conversation-outline.ts index bc4fa3b40ad..2b6bb846adb 100644 --- a/src/shared/agent-session-conversation-outline.ts +++ b/src/shared/agent-session-conversation-outline.ts @@ -75,8 +75,8 @@ export function truncateOutlinePreview(text: string, maxChars: number): string { /** User messages that draw a transcript row, in transcript order. Projected over the * whole journal, not user items alone: whether a user row survives depends on its - * neighbours (a harness sidecar folds into the turn before it) and its order on - * when it was observed. Previews are uncut; the reply bound owns length. */ + * neighbours (a harness sidecar folds into the turn before it), and its order is + * its journal position. Previews are uncut; the reply bound owns length. */ export function projectAgentSessionConversationOutline( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts new file mode 100644 index 00000000000..859d3782cc8 --- /dev/null +++ b/src/shared/agent-session-failure-words.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS, + AGENT_SESSION_FAILURE_KINDS, + isSubmissionRejectionFact, + readAgentSessionFailureFact, + type AgentSessionFailureFact, + type AgentSessionFailureKind, + type SubmissionRejectionKind +} from './agent-session-failure' +import { + agentSessionFailureSentence, + agentSessionFailureWords, + type AgentSessionFailureSurface +} from './agent-session-failure-words' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED +} from './structured-agent-session-dispatch-rejection' + +/** What Orca's own text looks like: a code or marker, a uuid, a path, an exception. */ +const ORCA_INTERNAL = /\b[a-z]+_[a-z_]+\b|[0-9a-f]{8}-[0-9a-f]{4}-|[/\\][\w.-]+[/\\]|Error:|ENOENT/ + +const SURFACES: readonly AgentSessionFailureSurface[] = ['row', 'rejection'] +const LEGACY_MARKER_KINDS: ReadonlySet = new Set([ + 'cancelled', + 'writeFailed', + 'queueFull' +]) + +/** Every fact shape a kind can carry: bare, with a person or a log detail, with a refusal, and + * for an attachment each reason with and without its limit. */ +function factsFor(kind: AgentSessionFailureKind): AgentSessionFailureFact[] { + const facts: AgentSessionFailureFact[] = [ + { kind }, + { kind, detail: { text: 'Context window exceeded.', audience: 'person' } }, + { + kind, + // A log detail and a refusal ride with the fact; neither may reach the sentence. + detail: { text: 'Error: ENOENT /Users/me/.claude/x agent_session_conflict', audience: 'log' }, + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } + } + ] + if (kind === 'providerRetrying') { + facts.push({ kind, retry: { error: 'rate_limit', status: 429 } }) + facts.push({ kind, retry: { error: 'overloaded', status: 529 } }) + facts.push({ kind, retry: { status: 500 } }) + } + if (kind === 'attachmentInvalid') { + for (const reason of AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS) { + facts.push({ kind, attachment: { reason } }) + facts.push({ kind, attachment: { reason, limit: 5 * 1024 * 1024 } }) + } + } + return facts +} + +describe('the words written beside a failure fact', () => { + describe.each(SURFACES)('on the %s surface', (surface) => { + it.each(AGENT_SESSION_FAILURE_KINDS)('for %s are one sentence a person can read', (kind) => { + for (const fact of factsFor(kind)) { + for (const [agentName, command] of [ + ['Claude', undefined], + [undefined, undefined], + ['Codex', 'clear'] + ] as const) { + const sentence = agentSessionFailureSentence(fact, surface, { agentName, command }) + expect([fact, sentence]).toEqual([fact, expect.stringMatching(/[^.]\.$/)]) + expect(sentence).not.toMatch(/\.\./) + expect(sentence).not.toMatch(ORCA_INTERNAL) + const context = { agentName, command, provider: 'claude' } as const + if (surface === 'row') { + expect(agentSessionFailureWords(fact, { ...context, surface }).text).toBe(sentence) + } else if (isSubmissionRejectionFact(fact) && !LEGACY_MARKER_KINDS.has(fact.kind)) { + expect(agentSessionFailureWords(fact, { ...context, surface }).reason).toBe(sentence) + } + } + } + }) + }) + + it('writes the legacy markers released clients hide, byte for byte, on a rejection', () => { + const reason = (kind: SubmissionRejectionKind, provider?: 'claude' | 'codex') => + agentSessionFailureWords({ kind }, { surface: 'rejection', provider }).reason + expect(reason('cancelled')).toBe(DISPATCH_REJECTED_CANCELLED) + expect(DISPATCH_REJECTED_CANCELLED).toBe('provider_cancelled_before_start') + expect(reason('queueFull', 'claude')).toBe(DISPATCH_REJECTED_QUEUE_FULL) + expect(DISPATCH_REJECTED_QUEUE_FULL).toBe('claude structured dispatch queue is full') + expect(reason('queueFull', 'codex')).toBe(DISPATCH_REJECTED_CODEX_QUEUE_FULL) + expect(DISPATCH_REJECTED_CODEX_QUEUE_FULL).toBe('codex structured dispatch queue is full') + // Bare: the error that once followed it belongs in the log. + expect(reason('writeFailed')).toBe(DISPATCH_REJECTED_WRITE_FAILED) + expect(DISPATCH_REJECTED_WRITE_FAILED).toBe('provider_write_failed') + // With no provider to name its marker, a full queue gets a sentence. + expect(reason('queueFull')).toBe( + 'Too many messages were waiting for the agent, so this one was not sent.' + ) + for (const kind of LEGACY_MARKER_KINDS) { + const written = agentSessionFailureWords( + { kind }, + { surface: 'rejection', provider: 'codex' } + ) + expect(classifyDispatchRejection({ reason: written.reason })).toMatchObject({ kind }) + } + }) + + it('names /clear as the next step for the start a /clear needed', () => { + const clear = (kind: AgentSessionFailureKind) => + agentSessionFailureSentence({ kind }, 'row', { agentName: 'Codex', command: 'clear' }) + expect(clear('notSignedIn')).toBe( + 'Codex is not signed in for the selected account. Sign in, then run /clear again.' + ) + expect(clear('startFailed')).toBe("Codex couldn't start. Run /clear again.") + expect(clear('restartFailed')).toBe("Codex couldn't restart. Run /clear again.") + expect(clear('providerStartFailed')).toBe( + 'Codex stopped before it finished starting. Run /clear again.' + ) + }) + + it('names the agent that stopped starting, and a failed start or restart gives a next step', () => { + const sentence = (kind: AgentSessionFailureKind, agentName?: string) => + agentSessionFailureSentence({ kind }, 'rejection', { agentName }) + expect(sentence('providerStartFailed', 'Claude')).toBe( + 'Claude stopped before it finished starting. Send your message to try again.' + ) + expect(sentence('providerStartFailed')).toBe( + 'The agent stopped before it finished starting. Send your message to try again.' + ) + expect(sentence('startFailed', 'Codex')).toBe( + "Codex couldn't start. Send your message to try again." + ) + expect(sentence('restartFailed')).toBe( + "The agent couldn't restart. Send your message to try again." + ) + // Nothing to restart from: a new chat is the only step, so no retry is offered. + expect( + agentSessionFailureSentence( + { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } + }, + 'row', + { agentName: 'Codex' } + ) + ).toBe("Codex couldn't start. Start a new chat to continue.") + }) + + it('names the exit a row reports differently from the message it left unsent', () => { + expect( + agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'row', agentName: 'Claude' }) + .text + ).toBe( + 'Claude stopped while this response was in progress. You can continue in this conversation.' + ) + expect( + agentSessionFailureWords( + { kind: 'providerExited' }, + { surface: 'rejection', agentName: 'Codex' } + ).reason + ).toBe('Codex stopped before this message was sent.') + expect(agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'row' }).text).toBe( + 'The agent stopped while this response was in progress. You can continue in this conversation.' + ) + expect( + agentSessionFailureWords({ kind: 'providerExited' }, { surface: 'rejection' }).reason + ).toBe('The agent stopped before this message was sent.') + }) + + it('says what frees a chat a terminal agent still holds, without naming its process', () => { + expect( + agentSessionFailureSentence( + { + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } + }, + 'row', + { agentName: 'Codex' } + ) + ).toBe( + "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." + ) + }) + + it('says which limit an attachment broke, in megabytes', () => { + const sentence = (attachment: AgentSessionFailureFact['attachment']) => + agentSessionFailureSentence({ kind: 'attachmentInvalid', attachment }, 'rejection', { + agentName: 'Claude' + }) + expect(sentence({ reason: 'tooLarge', limit: 5 * 1024 * 1024 })).toBe( + 'An image on this message is larger than 5 MB, so the message was not sent.' + ) + expect(sentence({ reason: 'totalTooLarge', limit: 20 * 1024 * 1024 })).toBe( + 'The images on this message add up to more than 20 MB, so the message was not sent.' + ) + expect(sentence({ reason: 'tooMany', limit: 20 })).toBe( + 'Claude accepts at most 20 images in one message, so this message was not sent.' + ) + expect(sentence(undefined)).toBe("An attachment on this message can't be sent to the agent.") + }) +}) + +describe('an attachment problem as a reader meets it', () => { + it('keeps what it can place and drops what it cannot', () => { + expect( + readAgentSessionFailureFact({ + kind: 'attachmentInvalid', + attachment: { reason: 'tooMany', limit: 20 } + }) + ).toEqual({ kind: 'attachmentInvalid', attachment: { reason: 'tooMany', limit: 20 } }) + expect( + readAgentSessionFailureFact({ + kind: 'attachmentInvalid', + attachment: { reason: 'tooMany', limit: 'twenty' } + }) + ).toEqual({ kind: 'attachmentInvalid', attachment: { reason: 'tooMany' } }) + // A reason a newer host added reads as the generic attachment failure. + expect( + readAgentSessionFailureFact({ + kind: 'attachmentInvalid', + attachment: { reason: 'futureReason' } + }) + ).toEqual({ kind: 'attachmentInvalid' }) + }) +}) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts new file mode 100644 index 00000000000..edd399edc47 --- /dev/null +++ b/src/shared/agent-session-failure-words.ts @@ -0,0 +1,273 @@ +// The sentence a person reads beside each failure fact, and the one constructor that writes both. +// +// A row's `text`, a rejected message's `reason` and a conversation command's `error` are what +// every released client prints as they are, so the host writes them here, from the fact, and +// nowhere else: never Orca's own error text, a refusal's message, or a probe's evidence. A +// provider's words reach the sentence only when the provider wrote them for a person. The table +// is also the English default for a client that chooses its own copy from the fact. + +import { + isSubmissionRejectionFact, + type AgentSessionAttachmentProblem, + type AgentSessionAttachmentProblemReason, + type AgentSessionFailureFact, + type AgentSessionFailureKind, + type ProviderDiagnostic, + type SubmissionRejectionFact, + type SubmissionRejectionKind +} from './agent-session-failure' +import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED +} from './structured-agent-session-dispatch-rejection' + +declare const failureSentence: unique symbol + +/** Words only `agentSessionFailureWords` makes, so no writer can put its own beside a fact. */ +export type AgentSessionFailureSentence = string & { readonly [failureSentence]: true } + +/** A status row that reports a failure. */ +export type AgentSessionFailureRowWords = { + text: AgentSessionFailureSentence + failure: AgentSessionFailureFact +} + +/** A rejection as the host writes it: the sentence (or, for the cases released clients already + * hide, the legacy marker) they print, and the fact newer ones read. */ +export type AgentJournalDispatchRejection = { + reason: AgentSessionFailureSentence + rejection: SubmissionRejectionFact +} + +/** `row`: a status row, about the chat. `rejection`: a rejected message's reason, about it. */ +export type AgentSessionFailureSurface = 'row' | 'rejection' + +export type AgentSessionFailureWordsContext = { + /** The chat's agent, when the writer knows it. */ + agentName?: string + /** Names the legacy queue-full marker; without it a full queue is worded as a sentence. */ + provider?: 'claude' | 'codex' + /** The conversation command a failed start was for, so the next step is to run it again + * rather than to send a message. */ + command?: 'clear' + /** The surface retries for the person — its own Retry beside the words, or a read that reconnects + * on its own — so they leave out sending or trying again. */ + retryControl?: boolean +} + +/** + * Whether a refused start leaves the chat anything to start again from. `false`: this host has + * nothing to restart it from — no record, or none it can run — so only a new chat continues. + * A new wire code does not compile until it is classified here. + */ +export const START_REFUSAL_RESUMABLE: Record = { + execution_owner_reconciling: true, + agent_session_conflict: true, + agent_session_checkpoint_stale: true, + agent_session_ownership_unknown: true, + agent_session_operation_capacity: true, + structured_agent_session_unsupported: false, + agent_session_operation_conflict: true, + agent_session_operation_expired: true, + agent_session_operation_invalid: true, + agent_session_operation_unknown: true, + agent_session_item_revision_stale: true, + agent_session_already_resolved: true, + agent_session_identity_required: false, + agent_session_journal_unreadable: true, + agent_session_owner_restart_failed: true +} + +/** Sentences a refusal notice shows too, so a chat says them one way. */ +export const TERMINAL_AGENT_HOLDS_CHAT = 'This chat is still open in a terminal agent.' +export const QUIT_TERMINAL_AGENT = 'Quit that agent to continue the chat here.' +export const START_NEW_CHAT = 'Start a new chat to continue.' + +/** Person-facing provider text is quoted, but bounded so the sentence stays one. */ +const MAX_QUOTED_DETAIL_CHARS = 512 +const BYTES_PER_MB = 1024 * 1024 + +type Sentence = ( + context: AgentSessionFailureWordsContext, + fact: AgentSessionFailureFact, + surface: AgentSessionFailureSurface +) => string + +function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefined): string { + const quoted = + detail?.audience === 'person' + ? detail.text + .slice(0, MAX_QUOTED_DETAIL_CHARS) + .trim() + .replace(/[.\s]+$/, '') + : '' + return quoted ? `${lead}: ${quoted}.` : `${lead}.` +} + +/** What to do once the start can work, for a sentence that ends in it. */ +function retryStep({ command }: AgentSessionFailureWordsContext): string { + return command === 'clear' ? 'run /clear again' : 'send your message again' +} + +/** The next step after a start or restart that failed: the command, or the message, again. */ +function startRetry({ command, retryControl }: AgentSessionFailureWordsContext): string { + if (retryControl) { + return '' + } + return command === 'clear' ? ' Run /clear again.' : ' Send your message to try again.' +} + +function couldNot(verb: string): Sentence { + return (context, fact) => { + const { agentName } = context + const failed = `${agentName ?? 'The agent'} couldn't ${verb}.` + // Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. + if (fact.refusal?.details?.reason === 'claimConflicted') { + return `${failed} ${TERMINAL_AGENT_HOLDS_CHAT} ${QUIT_TERMINAL_AGENT}` + } + const code = fact.refusal?.code + return code && !START_REFUSAL_RESUMABLE[code] + ? `${failed} ${START_NEW_CHAT}` + : `${failed}${startRetry(context)}` + } +} + +function megabytes(bytes: number): string { + return `${Math.round((bytes / BYTES_PER_MB) * 10) / 10} MB` +} + +const NOT_SENT = 'so the message was not sent.' + +const ATTACHMENT_SENTENCES = { + empty: () => `An image on this message is empty, ${NOT_SENT}`, + tooLarge: (_, { limit }) => + limit + ? `An image on this message is larger than ${megabytes(limit)}, ${NOT_SENT}` + : `An image on this message is too large, ${NOT_SENT}`, + tooMany: ({ agentName }, { limit }) => + limit + ? `${agentName ?? 'The agent'} accepts at most ${limit} images in one message, so this message was not sent.` + : 'This message has too many images, so it was not sent.', + totalTooLarge: (_, { limit }) => + limit + ? `The images on this message add up to more than ${megabytes(limit)}, ${NOT_SENT}` + : `The images on this message are too large together, ${NOT_SENT}`, + unsupportedType: ({ agentName }) => + `${agentName ?? 'The agent'} accepts only PNG, JPEG, GIF, and WebP images, so this message was not sent.`, + notAFile: () => `An image on this message isn't a file, ${NOT_SENT}`, + noSource: () => `An image on this message has no file to send, ${NOT_SENT}` +} satisfies Record< + AgentSessionAttachmentProblemReason, + (context: AgentSessionFailureWordsContext, problem: AgentSessionAttachmentProblem) => string +> + +const FAILURE_SENTENCES = { + providerStartFailed: (context) => + `${context.agentName ?? 'The agent'} stopped before it finished starting.${startRetry(context)}`, + startFailed: couldNot('start'), + // Beside a Retry the resend is the button, but signing in is still a step to take first. + notSignedIn: (context) => + `${context.agentName ?? 'The agent'} is not signed in for the selected account. ${context.retryControl ? 'Sign in first.' : `Sign in, then ${retryStep(context)}.`}`, + historyTooLarge: () => + `This conversation's history is too large to restore here. ${START_NEW_CHAT}`, + managedAccountEnvOverride: () => + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.', + accountSwitchInProgress: () => + 'A Claude account switch is in progress. Try again after it finishes.', + managedAccountUnsupported: (context) => + `While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings${context.retryControl ? '' : `, then ${retryStep(context)}`}.`, + providerExited: ({ agentName }, _, surface) => + surface === 'row' + ? `${agentName ?? 'The agent'} stopped while this response was in progress. You can continue in this conversation.` + : `${agentName ?? 'The agent'} stopped before this message was sent.`, + restartFailed: couldNot('restart'), + providerRejected: (_, fact) => + quotingPersonDetail('The provider did not accept this message', fact.detail), + attachmentInvalid: (context, fact) => + fact.attachment + ? ATTACHMENT_SENTENCES[fact.attachment.reason](context, fact.attachment) + : "An attachment on this message can't be sent to the agent.", + attachmentUnreadable: () => `An attachment on this message couldn't be read, ${NOT_SENT}`, + emptyMessage: () => 'This message is empty, so it was not sent.', + queueFull: () => 'Too many messages were waiting for the agent, so this one was not sent.', + writeFailed: () => "Orca couldn't hand this message to the agent, so it was not sent.", + cancelled: () => 'This message was withdrawn before the agent started it.', + chatClosed: () => 'The chat closed before this message was sent.', + hostRestarted: () => 'Orca restarted before this message was sent.', + notDelivered: ({ retryControl }) => + retryControl + ? 'This message was not delivered.' + : 'This message was not delivered. Send it again to continue.', + compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail), + compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', + cancelUnconfirmed: () => 'Cancellation was not confirmed.', + answerUnconfirmed: () => 'Your answer was recorded but the agent did not confirm it.', + hostFault: ({ retryControl }) => + `Orca ran into a problem, so this didn't go through.${retryControl ? '' : ' Try again.'}`, + hostStopped: ({ agentName }) => + `${agentName ?? 'The agent'} never finished starting, so Orca stopped it.`, + providerRetrying: ({ agentName }, { retry }) => + retry?.error === 'rate_limit' || retry?.status === 429 + ? `${agentName ?? 'The agent'} is rate-limited and retrying.` + : `${agentName ?? 'The agent'} hit a temporary problem and is retrying.` +} satisfies Record + +/** The sentence a person reads for this fact on this surface; never a marker. */ +export function agentSessionFailureSentence( + fact: AgentSessionFailureFact, + surface: AgentSessionFailureSurface, + context: AgentSessionFailureWordsContext = {} +): string { + const sentence: Sentence = FAILURE_SENTENCES[fact.kind] + return sentence(context, fact, surface) +} + +/** The markers released clients hide, for the rejections that had one before rows carried a fact. + * A write failure is the bare marker: its error belongs in the log. */ +const LEGACY_REJECTION_MARKERS: Partial< + Record string | undefined> +> = { + cancelled: () => DISPATCH_REJECTED_CANCELLED, + writeFailed: () => DISPATCH_REJECTED_WRITE_FAILED, + queueFull: ({ provider }) => + provider === 'codex' + ? DISPATCH_REJECTED_CODEX_QUEUE_FULL + : provider === 'claude' + ? DISPATCH_REJECTED_QUEUE_FULL + : undefined +} + +/** The words a status row reporting this fact records. */ +export function agentSessionFailureWords( + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext & { surface: 'row' } +): AgentSessionFailureRowWords +/** The words a message rejected for this fact records. */ +export function agentSessionFailureWords( + fact: SubmissionRejectionFact, + context: AgentSessionFailureWordsContext & { surface: 'rejection' } +): AgentJournalDispatchRejection +export function agentSessionFailureWords( + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext & { surface: AgentSessionFailureSurface } +): AgentSessionFailureRowWords | AgentJournalDispatchRejection { + if (context.surface === 'row') { + return { text: branded(agentSessionFailureSentence(fact, 'row', context)), failure: fact } + } + // The rejection overload admits only these; a caller that got past the types is Orca's bug. + if (!isSubmissionRejectionFact(fact)) { + throw new Error(`agent session failure kind ${fact.kind} cannot reject a message`) + } + const words = + LEGACY_REJECTION_MARKERS[fact.kind]?.(context) ?? + agentSessionFailureSentence(fact, 'rejection', context) + return { reason: branded(words), rejection: fact } +} + +function branded(words: string): AgentSessionFailureSentence { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only `agentSessionFailureWords` calls this, with words from the table or a legacy marker for its fact. + return words as AgentSessionFailureSentence +} diff --git a/src/shared/agent-session-failure.test.ts b/src/shared/agent-session-failure.test.ts new file mode 100644 index 00000000000..4dc12f9bfe8 --- /dev/null +++ b/src/shared/agent-session-failure.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_PROVIDER_DIAGNOSTIC_CHARS, + providerDiagnostic, + providerDiagnosticOf, + readAgentSessionFailureFact, + withProviderDiagnostic +} from './agent-session-failure' + +describe('provider diagnostics', () => { + it('bounds the text and records nothing for an empty one', () => { + expect(providerDiagnostic(' ', 'log')).toBeUndefined() + expect( + providerDiagnostic('x'.repeat(MAX_PROVIDER_DIAGNOSTIC_CHARS + 50), 'log')?.text + ).toHaveLength(MAX_PROVIDER_DIAGNOSTIC_CHARS) + }) + + it('is read from the error that carries it, or from what it wraps', () => { + const diagnostic = { text: 'bad request', audience: 'person' as const } + const carried = withProviderDiagnostic( + new Error('codex turn/start failed: bad request'), + diagnostic + ) + expect(providerDiagnosticOf(carried)).toEqual(diagnostic) + expect(providerDiagnosticOf(new Error('wrapped', { cause: carried }))).toEqual(diagnostic) + expect(providerDiagnosticOf(new AggregateError([new Error('other'), carried], 'both'))).toEqual( + diagnostic + ) + }) + + it('ends on an aggregate error that contains itself', () => { + const loop = new AggregateError([], 'loop') + loop.errors.push(loop) + expect(providerDiagnosticOf(loop)).toBeUndefined() + }) + + it('is never inferred from an error that did not carry one', () => { + // Orca's own wording, even when it quotes something that looks like a provider message. + expect( + providerDiagnosticOf(new Error('claude stream-json exited (code 1): not signed in')) + ).toBe(undefined) + expect(providerDiagnosticOf('a string')).toBeUndefined() + }) +}) + +describe('reading a failure fact', () => { + it('keeps what this build can place', () => { + expect( + readAgentSessionFailureFact({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } }, + detail: { text: 'x', audience: 'person' } + }) + ).toEqual({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } }, + detail: { text: 'x', audience: 'person' } + }) + }) + + it('keeps what a provider said it is retrying, and only that', () => { + expect( + readAgentSessionFailureFact({ + kind: 'providerRetrying', + retry: { error: 'rate_limit', status: 429, attempt: 3 } + }) + ).toEqual({ kind: 'providerRetrying', retry: { error: 'rate_limit', status: 429 } }) + expect( + readAgentSessionFailureFact({ kind: 'providerRetrying', retry: { error: '', status: 'x' } }) + ).toEqual({ kind: 'providerRetrying' }) + }) + + it('reads a row an unreleased build wrote with a cause as a refusal with no details', () => { + expect( + readAgentSessionFailureFact({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', cause: 'claimConflicted' } + }) + ).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } }) + }) + + it('drops what a newer host wrote that this build cannot place', () => { + expect(readAgentSessionFailureFact({ kind: 'futureKind' })).toBeUndefined() + expect(readAgentSessionFailureFact(undefined)).toBeUndefined() + expect( + readAgentSessionFailureFact({ + kind: 'restartFailed', + refusal: { code: 'agent_session_conflict', details: { reason: 'futureReason' } }, + detail: { text: 'x', audience: 'future' } + }) + ).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } }) + }) +}) diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts new file mode 100644 index 00000000000..ed8b677b359 --- /dev/null +++ b/src/shared/agent-session-failure.ts @@ -0,0 +1,262 @@ +// What went wrong in a chat, typed where the host decides it, beside the sentence a person reads. +// +// The host writes both at once: `text`/`reason` stays a complete sentence because released clients +// print it as it is, and this fact is what newer clients choose their copy and action from. A +// provider's own words ride separately as `detail`, set only where Orca composed them from a +// value the provider wrote — never recovered from a string afterwards, since by then nothing can +// tell a provider's sentence from Orca's. + +import { + readAgentSessionRefusalReference, + type AgentSessionRefusalReference +} from './agent-session-wire-refusals' + +/** One per situation with its own honest next step; a new one needs copy before it compiles. */ +export const AGENT_SESSION_FAILURE_KINDS = [ + /** Only an observed exit: the provider stopped while starting. */ + 'providerStartFailed', + /** A start that did not land, with no one to blame: Orca's fault, a failed spawn, a close. */ + 'startFailed', + 'notSignedIn', + 'historyTooLarge', + 'managedAccountEnvOverride', + 'accountSwitchInProgress', + 'managedAccountUnsupported', + 'providerExited', + 'restartFailed', + 'providerRejected', + 'attachmentInvalid', + 'attachmentUnreadable', + 'emptyMessage', + 'queueFull', + 'writeFailed', + 'cancelled', + 'chatClosed', + 'hostRestarted', + 'notDelivered', + 'compactionFailed', + 'compactionUnconfirmed', + 'cancelUnconfirmed', + 'answerUnconfirmed', + 'hostFault', + /** Orca stopped an agent whose start never finished. */ + 'hostStopped', + /** The provider is retrying a request its API refused; not a failure yet. */ + 'providerRetrying' +] as const +export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] + +export function isAgentSessionFailureKind(value: unknown): value is AgentSessionFailureKind { + return typeof value === 'string' && AGENT_SESSION_FAILURE_KINDS.some((kind) => kind === value) +} + +/** Kinds only a status row reports: none is ever why a message was not sent. */ +const STATUS_ROW_ONLY_FAILURE_KINDS = [ + 'compactionFailed', + 'compactionUnconfirmed', + 'cancelUnconfirmed', + 'answerUnconfirmed', + 'providerRetrying' +] as const satisfies readonly AgentSessionFailureKind[] + +/** Why a message was not sent. A new failure kind is one of these until listed above. */ +export type SubmissionRejectionKind = Exclude< + AgentSessionFailureKind, + (typeof STATUS_ROW_ONLY_FAILURE_KINDS)[number] +> + +export function isSubmissionRejectionKind(value: unknown): value is SubmissionRejectionKind { + return ( + isAgentSessionFailureKind(value) && + !STATUS_ROW_ONLY_FAILURE_KINDS.some((statusOnly) => statusOnly === value) + ) +} + +/** `person`: written by the provider for whoever reads the chat, shown inline. `log`: a stderr + * tail or exit status, shown only behind Details. */ +export type ProviderDiagnosticAudience = 'person' | 'log' + +export type ProviderDiagnostic = { + text: string + audience: ProviderDiagnosticAudience +} + +/** Stderr can be a whole dump; the row keeps enough to act on. The same cap as the exit reason a + * lease record keeps, so a diagnostic never outgrows what the record may store. */ +export const MAX_PROVIDER_DIAGNOSTIC_CHARS = 512 + +/** Which of Orca's checks an image failed. A new one needs a sentence before it compiles. */ +export const AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS = [ + 'empty', + 'tooLarge', + 'tooMany', + 'totalTooLarge', + 'unsupportedType', + 'notAFile', + 'noSource' +] as const +export type AgentSessionAttachmentProblemReason = + (typeof AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS)[number] + +export type AgentSessionAttachmentProblem = { + reason: AgentSessionAttachmentProblemReason + /** The limit it broke: bytes for `tooLarge` and `totalTooLarge`, a count for `tooMany`. */ + limit?: number +} + +/** What the provider said it is retrying, in its own fields. */ +export type AgentSessionProviderRetry = { + /** The provider's error type, e.g. `rate_limit` or `overloaded`. */ + error?: string + /** The HTTP status the request failed with. */ + status?: number +} + +export type AgentSessionFailureFact = { + kind: AgentSessionFailureKind + /** Provider-authored only; absent whenever Orca wrote the words. */ + detail?: ProviderDiagnostic + /** On `restartFailed` and `startFailed`: the refusal that kept the agent from starting. */ + refusal?: AgentSessionRefusalReference + /** On `attachmentInvalid`: which check the attachment failed. */ + attachment?: AgentSessionAttachmentProblem + /** On `providerRetrying`: why the provider is retrying. */ + retry?: AgentSessionProviderRetry +} + +/** A fact as a row stores it: its kind may be one a newer host added, so only + * `readAgentSessionFailureFact` or the rejection classifier may place it. */ +export type UnreadAgentSessionFailureFact = { kind: string } + +/** A fact a rejected message may carry. */ +export type SubmissionRejectionFact = AgentSessionFailureFact & { kind: SubmissionRejectionKind } + +export function isSubmissionRejectionFact( + fact: AgentSessionFailureFact +): fact is SubmissionRejectionFact { + return isSubmissionRejectionKind(fact.kind) +} + +/** Null for empty text, so a writer never records a detail with nothing in it. */ +export function providerDiagnostic( + text: string, + audience: ProviderDiagnosticAudience +): ProviderDiagnostic | undefined { + const bounded = text.trim().slice(0, MAX_PROVIDER_DIAGNOSTIC_CHARS).trim() + return bounded ? { text: bounded, audience } : undefined +} + +export function agentSessionFailureFact( + kind: TKind, + extra: { + detail?: ProviderDiagnostic + refusal?: AgentSessionRefusalReference + attachment?: AgentSessionAttachmentProblem + retry?: AgentSessionProviderRetry + } = {} +): AgentSessionFailureFact & { kind: TKind } { + // Re-bounded here, so no writer can store more than the cap however it built the detail. + const detail = extra.detail + ? providerDiagnostic(extra.detail.text, extra.detail.audience) + : undefined + return { + kind, + ...(detail ? { detail } : {}), + ...(extra.refusal ? { refusal: extra.refusal } : {}), + ...(extra.attachment ? { attachment: extra.attachment } : {}), + ...(extra.retry ? { retry: extra.retry } : {}) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function isProviderDiagnostic(value: unknown): value is ProviderDiagnostic { + return ( + isRecord(value) && + typeof value.text === 'string' && + (value.audience === 'person' || value.audience === 'log') + ) +} + +function readAttachmentProblem(value: unknown): AgentSessionAttachmentProblem | undefined { + if (!isRecord(value)) { + return undefined + } + const reason = AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS.find((known) => known === value.reason) + if (!reason) { + return undefined + } + const limit = value.limit + return typeof limit === 'number' && Number.isFinite(limit) && limit > 0 + ? { reason, limit } + : { reason } +} + +/** A retry as a reader meets it; undefined when it names neither field. */ +export function readProviderRetry(value: unknown): AgentSessionProviderRetry | undefined { + if (!isRecord(value)) { + return undefined + } + const error = + typeof value.error === 'string' && value.error.trim() ? value.error.trim() : undefined + const status = + typeof value.status === 'number' && Number.isInteger(value.status) && value.status > 0 + ? value.status + : undefined + return error || status + ? { ...(error ? { error } : {}), ...(status ? { status } : {}) } + : undefined +} + +/** A fact as a reader meets it. Undefined for anything this build cannot place, including a kind a + * newer host added, so the reader falls back to what it does for a row with no fact. */ +export function readAgentSessionFailureFact(value: unknown): AgentSessionFailureFact | undefined { + if (!isRecord(value) || !isAgentSessionFailureKind(value.kind)) { + return undefined + } + const refusal = readAgentSessionRefusalReference(value.refusal) + const attachment = readAttachmentProblem(value.attachment) + const retry = readProviderRetry(value.retry) + return agentSessionFailureFact(value.kind, { + ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), + ...(refusal ? { refusal } : {}), + ...(attachment ? { attachment } : {}), + ...(retry ? { retry } : {}) + }) +} + +/** The provider-authored diagnostic an error carries, set only where it was composed. Follows the + * `cause` chain, since wrappers such as the acquisition errors keep the original as their cause. */ +export function providerDiagnosticOf(error: unknown): ProviderDiagnostic | undefined { + return providerDiagnosticWithin(error, 0) +} + +// One depth bound across `cause` and aggregated errors, so an error that contains itself ends. +function providerDiagnosticWithin(error: unknown, start: number): ProviderDiagnostic | undefined { + let current: unknown = error + for (let depth = start; depth < 6 && current instanceof Error; depth += 1) { + if ('providerDiagnostic' in current && isProviderDiagnostic(current.providerDiagnostic)) { + return current.providerDiagnostic + } + if (current instanceof AggregateError) { + for (const inner of current.errors) { + const found = providerDiagnosticWithin(inner, depth + 1) + if (found) { + return found + } + } + } + current = current.cause + } + return undefined +} + +/** Attaches the provider's words to the error Orca built around them. */ +export function withProviderDiagnostic( + error: TError, + diagnostic: ProviderDiagnostic | undefined +): TError { + return diagnostic ? Object.assign(error, { providerDiagnostic: diagnostic }) : error +} diff --git a/src/shared/agent-session-journal-position.ts b/src/shared/agent-session-journal-position.ts new file mode 100644 index 00000000000..9e317f1ee71 --- /dev/null +++ b/src/shared/agent-session-journal-position.ts @@ -0,0 +1,36 @@ +// The journal's own order: the sequence of the row that created an item, then the +// item's place among that row's writes. Time never enters it — a row recovered +// after a crash carries the provider's earlier clock at a later sequence. + +import type { AgentJournalPosition, AgentJournalRenderItem } from './agent-session-journal-types' +import type { NativeChatMessage } from './native-chat-types' + +type PositionedItem = Pick + +/** What every transcript row projected from a journal item carries, so a row + * that changes shape (a question once answered) keeps its identity and place. */ +export function agentJournalItemRowOrigin( + item: AgentJournalRenderItem +): Pick { + return { + id: item.itemId, + timestamp: item.observedAt, + source: 'transcript', + journalPosition: agentJournalItemPosition(item) + } +} + +export function agentJournalItemPosition(item: PositionedItem): AgentJournalPosition { + return { sequence: item.sequence, index: item.sequenceIndex ?? 0 } +} + +export function compareAgentJournalPositions( + a: AgentJournalPosition, + b: AgentJournalPosition +): number { + return a.sequence - b.sequence || a.index - b.index +} + +export function compareAgentJournalItems(a: PositionedItem, b: PositionedItem): number { + return a.sequence - b.sequence || (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0) +} diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index ec66caaa059..594d6a04b11 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -332,6 +332,57 @@ describe('optional notice metadata', () => { }) }) +describe('typed failure facts', () => { + it('admits a status row and a submission with a fact, and the same rows without one', () => { + const failure = { + kind: 'providerStartFailed', + detail: { text: 'exit status 1', audience: 'log' } + } + expect(isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'Stopped.', failure })).toBe( + true + ) + expect(isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'Stopped.' })).toBe(true) + const submission = { + clientMessageId: 'cm-1', + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Not sent.', + submittedAt: 1, + resolvedAt: 2 + } + expect(isAdmissibleAgentJournalSubmission(submission)).toBe(true) + expect(isAdmissibleAgentJournalSubmission({ ...submission, rejection: failure })).toBe(true) + }) + + it("admits a refusal's details, and a row an earlier build wrote with its cause", () => { + const refused = (refusal: Record) => + isAdmissibleAgentJournalItemBody({ + kind: 'status', + text: "Codex couldn't restart.", + failure: { kind: 'restartFailed', refusal } + }) + expect( + refused({ + code: 'agent_session_conflict', + details: { reason: 'claimConflicted', futureFact: 1 } + }) + ).toBe(true) + expect(refused({ code: 'agent_session_conflict', cause: 'claimConflicted' })).toBe(true) + }) + + it('keeps a kind or audience a newer host writes admissible', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'status', + text: 'Stopped.', + failure: { kind: 'futureKind', detail: { text: 'x', audience: 'future' } } + }) + ).toBe(true) + }) +}) + describe('optional tool annotations', () => { const body = { kind: 'tool-call', name: 'shell', input: null, state: 'completed' } it('admits old rows and rows with optional annotations without a new kind', () => { diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 04f7183eb3f..d193db01846 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -18,6 +18,7 @@ import { AgentSessionContextUsageSchema } from './agent-session-context-usage-sc import type { AgentJournalItemBody, AgentJournalMessageItem, + AgentJournalResolution, AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' @@ -199,6 +200,14 @@ const ThreadGoalState = z.union([ z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) ]) +/** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row + * malformed; the fact reader is where an unplaceable one is dropped. */ +const FailureFact = z.object({ + kind: z.string().min(1), + detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), + refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() +}) + export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ MessageBody, z.object({ @@ -251,7 +260,8 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ }) .optional(), providerFrame: ProviderFrame.optional(), - threadGoal: ThreadGoalState.optional() + threadGoal: ThreadGoalState.optional(), + failure: FailureFact.optional() }), z.object({ kind: z.literal('turn'), @@ -289,6 +299,7 @@ export const AgentJournalRenderItemSchema = z.object({ revision: z.number().int(), body: AgentJournalItemBodySchema, sequence: z.number().int(), + sequenceIndex: z.number().int().nonnegative().optional(), observedAt: z.number(), recovered: z.literal(true).optional(), recoveredAt: z.number().optional(), @@ -306,9 +317,14 @@ export const AgentJournalSubmissionSchema = z.object({ resolvedAt: z.number().nullable(), recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), - handedOverAt: z.number().optional() + handedOverAt: z.number().optional(), + rejection: FailureFact.optional() }) +export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution { + return Resolution.safeParse(value).success +} + export function isAdmissibleAgentJournalItemBody(value: unknown): value is AgentJournalItemBody { return AgentJournalItemBodySchema.safeParse(value).success } diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 0a6cb655cd8..a17561e6b81 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -7,6 +7,8 @@ // rewritten in place, so a host that cannot read a row refuses to write the // journal rather than skipping or compacting past it. +import type { UnreadAgentSessionFailureFact } from './agent-session-failure' +import type { AgentSessionFailureRowWords } from './agent-session-failure-words' import type { AgentType } from './agent-status-types' import type { AgentSessionQuestionAnswer } from './agent-session-question-answer' import type { AgentJournalTurnOutcome } from './agent-turn-outcome' @@ -257,9 +259,8 @@ export type AgentJournalThreadGoalState = | { state: 'set'; goal: AgentJournalThreadGoal } | { state: 'cleared' } -export type AgentJournalStatusItem = { +type AgentJournalStatusItemFields = { kind: 'status' - text: string /** Optional display hints; unknown values retain the ordinary text fallback. */ presentation?: string tone?: string @@ -278,6 +279,19 @@ export type AgentJournalStatusItem = { threadGoal?: AgentJournalThreadGoalState } +/** A status row that reports no failure; its text is its writer's own. */ +export type AgentJournalPlainStatusItem = AgentJournalStatusItemFields & { + text: string + failure?: undefined +} + +export type AgentJournalStatusItem = + | AgentJournalPlainStatusItem + | (AgentJournalStatusItemFields & + /** A row that reports a failure: what failed, typed, beside the sentence older clients print, + * both from `agentSessionFailureWords`. Absent on rows from older hosts. */ + AgentSessionFailureRowWords) + /** The durable record of one root turn. `running` exposes cancellation while * the provider can still accept it; the item is revised to a terminal state, * never tombstoned, so the endpoints survive. Timestamps are the execution @@ -327,6 +341,13 @@ export type AgentJournalProducerLinkage = { attempt?: number } +/** Where the journal placed an item: the sequence of the row that created it, + * then its place among that row's writes. The timeline's only ordering key. */ +export type AgentJournalPosition = { + sequence: number + index: number +} + /** One reduced timeline entry. `sequence` orders the list; `observedAt` is the * provider's own clock and may sort earlier than a later sequence when the row * was recovered after a crash. */ @@ -335,6 +356,9 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { revision: number body: AgentJournalItemBody sequence: number + /** Place among the writes of the row at `sequence`, which one lifecycle batch + * shares across every item it creates. Absent ⇒ 0, and on a host that predates it. */ + sequenceIndex?: number observedAt: number /** Set when the row was appended by crash reconciliation rather than live. */ recovered?: true @@ -357,8 +381,11 @@ export type AgentJournalSubmission = { dispatchState: AgentJournalDispatchState /** Provider item identity adopted on accept; null otherwise. */ providerItemId: string | null - /** Terminal reason on `rejected`. */ + /** Terminal reason on `rejected`: a sentence a person can read, or one of the legacy markers + * older clients already recognise. On `unknown`, the doubt marker. */ reason: string | null + /** On `rejected`, why, typed; absent on rows from older hosts. */ + rejection?: UnreadAgentSessionFailureFact submittedAt: number resolvedAt: number | null /** Set when crash reconciliation resolved the dispatch, not the provider. A live diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts index 2216782b5e5..602a263e91b 100644 --- a/src/shared/agent-session-lease-adjudication.test.ts +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -91,7 +91,11 @@ describe('acquisition compare-and-swap', () => { handoffOperationId: null, probe: MATCHED }) - ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + }) expect(acquire(held, MATCHED)).toEqual({ decision: 'granted', nextFence: 8 }) }) @@ -108,7 +112,11 @@ describe('acquisition compare-and-swap', () => { handoffOperationId: null, probe: MATCHED }) - ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + }) }) it('never grants a second owner on expiry alone', () => { @@ -116,11 +124,13 @@ describe('acquisition compare-and-swap', () => { const expired = lease({ leaseDeadlineAt: 1, lastRenewedAt: 1 }) expect(acquire(expired, INDETERMINATE)).toEqual({ decision: 'refused', - code: 'agent_session_ownership_unknown' + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } }) expect(acquire(expired, MATCHED)).toEqual({ decision: 'refused', - code: 'agent_session_conflict' + code: 'agent_session_conflict', + details: { reason: 'ownerAlive' } }) expect(acquire(expired, { outcome: 'pid-absent' })).toEqual({ decision: 'granted', @@ -131,7 +141,8 @@ describe('acquisition compare-and-swap', () => { it('refuses while unreconciled even with proof the owner is dead', () => { expect(acquire(lease({ unreconciled: true }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', - code: 'execution_owner_reconciling' + code: 'execution_owner_reconciling', + details: { reason: 'hostReconciling' } }) }) @@ -139,14 +150,16 @@ describe('acquisition compare-and-swap', () => { // Restart adjudication and recovery resolution are what retire it, once its owner is gone. expect(acquire(lease({ claimStatus: 'conflicted' }), { outcome: 'exit-observed' })).toEqual({ decision: 'refused', - code: 'agent_session_conflict' + code: 'agent_session_conflict', + details: { reason: 'claimConflicted' } }) }) it('refuses acquisition in the recovering stage', () => { expect(acquire(lease({ handoffStage: 'recovering' }), { outcome: 'pid-absent' })).toEqual({ decision: 'refused', - code: 'agent_session_ownership_unknown' + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } }) }) @@ -159,7 +172,8 @@ describe('acquisition compare-and-swap', () => { }) expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-2')).toEqual({ decision: 'refused', - code: 'agent_session_operation_conflict' + code: 'agent_session_operation_conflict', + details: { reason: 'handoffInFlight' } }) expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-1')).toEqual({ decision: 'retry-reservation', @@ -171,7 +185,8 @@ describe('acquisition compare-and-swap', () => { const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', handoffStage: null }) expect(acquire(reserved, INDETERMINATE)).toEqual({ decision: 'refused', - code: 'agent_session_ownership_unknown' + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } }) expect(acquire(reserved, { outcome: 'reservation-unused' })).toEqual({ decision: 'granted', diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index ecdb1cecc3a..bbe409a37fc 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -15,7 +15,10 @@ import type { AgentSessionHandoffStage, AgentSessionLease } from './agent-session-record' -import type { AgentSessionOwnerVerdict } from './agent-session-wire-refusals' +import type { + AgentSessionOwnerVerdict, + AgentSessionRefusalDetailsByCode +} from './agent-session-wire-refusals' export type AgentSessionIdentityMatchField = 'process-start-time' | 'spawn-token' @@ -44,7 +47,13 @@ export type AgentSessionAcquisitionDecision = | { decision: 'granted'; nextFence: number } /** The same acquisition operation re-entering its own reservation; no new fence, no new spawn. */ | { decision: 'retry-reservation'; fence: number } - | { decision: 'refused'; code: AgentSessionLeaseRefusalCode } + | { + [C in AgentSessionLeaseRefusalCode]: { + decision: 'refused' + code: C + details: AgentSessionRefusalDetailsByCode[C] + } + }[AgentSessionLeaseRefusalCode] export type AgentSessionRestartAdjudication = /** Nothing is outstanding — no owner, no reservation. Clear any latched stage; the fence stays. */ @@ -131,23 +140,43 @@ export function evaluateAgentSessionAcquisition(args: { }): AgentSessionAcquisitionDecision { const { lease, expectedFence, handoffOperationId, probe } = args if (lease.unreconciled) { - return { decision: 'refused', code: 'execution_owner_reconciling' } + return { + decision: 'refused', + code: 'execution_owner_reconciling', + details: { reason: 'hostReconciling' } + } } if (!isAgentSessionFenceCurrent(lease, expectedFence)) { - return { decision: 'refused', code: 'agent_session_checkpoint_stale' } + return { + decision: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + } } if (lease.claimStatus === 'conflicted') { // Why: the user's own terminal agent; restart adjudication and recovery retire it once gone. - return { decision: 'refused', code: 'agent_session_conflict' } + return { + decision: 'refused', + code: 'agent_session_conflict', + details: { reason: 'claimConflicted' } + } } if (lease.handoffStage === 'recovering') { // Why: no stage expires into an owner; recovery resolution concludes about it first. - return { decision: 'refused', code: 'agent_session_ownership_unknown' } + return { + decision: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } } if (lease.handoffStage !== null && lease.handoffOperationId !== null) { if (handoffOperationId !== lease.handoffOperationId) { // Why: the retry key is operation id + fence + stage; a different id is a different intent. - return { decision: 'refused', code: 'agent_session_operation_conflict' } + return { + decision: 'refused', + code: 'agent_session_operation_conflict', + details: { reason: 'handoffInFlight' } + } } if ( lease.ownerProcess === null && @@ -162,19 +191,24 @@ export function evaluateAgentSessionAcquisition(args: { if (!isProvenDeadProbe(probe)) { // Why: a lapsed deadline means Orca stopped hearing from the owner, not that the child // stopped editing files and spending tokens. - return { - decision: 'refused', - code: isProvenAliveProbe(probe) - ? 'agent_session_conflict' - : 'agent_session_ownership_unknown' - } + return isProvenAliveProbe(probe) + ? { decision: 'refused', code: 'agent_session_conflict', details: { reason: 'ownerAlive' } } + : { + decision: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } } return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } } if (lease.claimStatus === 'reserved' && probe.outcome !== 'reservation-unused') { // Why: a reservation with no proven process is not a free lease — the crash may have lost // the race with the spawn rather than beaten it. - return { decision: 'refused', code: 'agent_session_ownership_unknown' } + return { + decision: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven' } + } } return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } } diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts index 306450f8a63..028de74935a 100644 --- a/src/shared/agent-session-mutation-envelope.test.ts +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -163,11 +163,18 @@ describe('admitAgentSessionMutation', () => { it('surfaces a ledger refusal verbatim', () => { const admission = admitAgentSessionMutation({ ...base, - ledger: { decision: 'refused', code: 'agent_session_operation_expired' } + ledger: { + decision: 'refused', + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } }) expect(admission).toMatchObject({ decision: 'refused', - refusal: { code: 'agent_session_operation_expired' } + refusal: { + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } }) }) }) diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index dd409d406d1..770c184b562 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -15,6 +15,7 @@ import { agentSessionLeaseAdmitsWriter } from './agent-session-lease-adjudicatio import type { AgentSessionLease } from './agent-session-record' import { terminalOwnerRefusalMessage } from './agent-session-legacy-handoff-lease' import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire' +import { refuse } from './agent-session-wire-refusals' /** * Stable digest over the fields that define what this call DOES. Keys are @@ -64,11 +65,11 @@ export function agentSessionFingerprintConflict( ): AgentSessionWireRefusal | null { return envelope.payloadFingerprint === hostFingerprint ? null - : { - code: 'agent_session_operation_conflict', - message: - 'The payload does not match the fingerprint the client declared for this operation.' - } + : refuse( + 'agent_session_operation_conflict', + { reason: 'fingerprintMismatch' }, + 'The payload does not match the fingerprint the client declared for this operation.' + ) } export type AgentSessionMutationAdmission = @@ -104,10 +105,11 @@ export function admitAgentSessionMutation(input: { if (ledger.decision === 'refused') { return { decision: 'refused', - refusal: { - code: ledger.code, - message: `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` - } + refusal: refuse( + ledger.code, + ledger.details, + `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` + ) } } if (ledger.decision === 'replay') { @@ -130,24 +132,31 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR return null } if (lease.unreconciled) { - return { - code: 'execution_owner_reconciling', - message: 'This host has not yet adjudicated the session lease.' - } + return refuse( + 'execution_owner_reconciling', + { reason: 'hostReconciling' }, + 'This host has not yet adjudicated the session lease.' + ) } if (lease.handoffStage !== null) { - return { - code: 'agent_session_conflict', - message: - lease.claimStatus === 'conflicted' - ? terminalOwnerRefusalMessage(lease) - : lease.handoffStage === 'new-owner-proving' - ? 'The chat is still starting.' - : "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." + if (lease.claimStatus === 'conflicted') { + return refuse( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + terminalOwnerRefusalMessage(lease) + ) } + return lease.handoffStage === 'new-owner-proving' + ? refuse('agent_session_conflict', { reason: 'chatStarting' }, 'The chat is still starting.') + : refuse( + 'agent_session_conflict', + { reason: 'ownerUnproven' }, + "Orca has not yet confirmed that this chat's previous agent process stopped. Reopen the chat to check again." + ) } - return { - code: 'agent_session_ownership_unknown', - message: 'The session has no live owner to accept writes.' - } + return refuse( + 'agent_session_ownership_unknown', + { reason: 'noLiveOwner' }, + 'The session has no live owner to accept writes.' + ) } diff --git a/src/shared/agent-session-operation-ledger.test.ts b/src/shared/agent-session-operation-ledger.test.ts index 0710eade461..ef10fac8f2b 100644 --- a/src/shared/agent-session-operation-ledger.test.ts +++ b/src/shared/agent-session-operation-ledger.test.ts @@ -64,7 +64,8 @@ describe('operation admission', () => { admit(rows) expect(evaluate(rows, { fingerprint: 'fp-2' })).toEqual({ decision: 'refused', - code: 'agent_session_operation_conflict' + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } }) }) @@ -78,14 +79,19 @@ describe('operation admission', () => { const rows = new Map() expect(evaluate(rows, { operationId: 'not-an-operation-id' })).toEqual({ decision: 'refused', - code: 'agent_session_operation_invalid' + code: 'agent_session_operation_invalid', + details: { reason: 'operationIdInvalid' } }) // Why: a future-dated id would look new again after its own tombstone is collected. expect( evaluate(rows, { operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 1) }) - ).toEqual({ decision: 'refused', code: 'agent_session_operation_invalid' }) + ).toEqual({ + decision: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'operationIdInvalid' } + }) expect( evaluate(rows, { operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS) }) .decision @@ -97,7 +103,8 @@ describe('operation admission', () => { const stale = operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1) expect(evaluate(rows, { operationId: stale })).toEqual({ decision: 'refused', - code: 'agent_session_operation_expired' + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } }) expect( evaluate(rows, { operationId: operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) }) @@ -110,12 +117,14 @@ describe('operation admission', () => { admit(rows, { operationId: operationId(NOW, 'b'.repeat(32)) }) expect(evaluate(rows, { perClientLimit: 1 })).toEqual({ decision: 'refused', - code: 'agent_session_operation_capacity' + code: 'agent_session_operation_capacity', + details: { reason: 'operationCapacity' } }) // A different caller is still refused once the global cap is reached. expect(evaluate(rows, { callerKey: 'client-2', globalLimit: 1 })).toEqual({ decision: 'refused', - code: 'agent_session_operation_capacity' + code: 'agent_session_operation_capacity', + details: { reason: 'operationCapacity' } }) expect(evaluate(rows, { callerKey: 'client-2', perClientLimit: 1 }).decision).toBe('admit') }) diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 18d0f18754b..90caec22bb7 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -1,3 +1,7 @@ +import type { + AgentSessionAnyRefusalDetails, + AgentSessionRefusalDetailsByCode +} from './agent-session-refusal-details' import { isAgentSessionRewindResult, type AgentSessionRewindReason, @@ -55,7 +59,15 @@ export type AgentSessionOperationOutcome = */ launch?: unknown } - | { status: 'failed'; code: string; message?: string; rewindReason?: AgentSessionRewindReason } + | { + status: 'failed' + code: string + message?: string + rewindReason?: AgentSessionRewindReason + /** Beside the code, so a replay says what the first answer did. Read back against the code, + * since the code is a string here; a row written before details carries none. */ + details?: AgentSessionAnyRefusalDetails + } /** The effect may or may not have happened; replay this answer instead of spawning again. */ | { status: 'unknown' } @@ -78,7 +90,13 @@ export type AgentSessionOperationRefusalCode = export type AgentSessionOperationDecision = | { decision: 'replay'; row: AgentSessionOperationRow } | { decision: 'admit'; row: AgentSessionOperationRow } - | { decision: 'refused'; code: AgentSessionOperationRefusalCode } + | { + [C in AgentSessionOperationRefusalCode]: { + decision: 'refused' + code: C + details: AgentSessionRefusalDetailsByCode[C] + } + }[AgentSessionOperationRefusalCode] /** NUL cannot occur in a caller key or operation id, so no pair can forge another pair's key. */ const OPERATION_KEY_SEPARATOR = '\u0000' @@ -228,19 +246,31 @@ export function evaluateAgentSessionOperation(args: { operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS ) { // Why: a future-dated id could look new again after its tombstone is collected. - return { decision: 'refused', code: 'agent_session_operation_invalid' } + return { + decision: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'operationIdInvalid' } + } } const key = agentSessionOperationKey(callerKey, operationId) const existing = rows.get(key) if (existing) { return existing.fingerprint === fingerprint ? { decision: 'replay', row: existing } - : { decision: 'refused', code: 'agent_session_operation_conflict' } + : { + decision: 'refused', + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + } } if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { // Why: once a tombstone could have expired, an unseen replay must never be reinterpreted as // permission to start another fresh agent. - return { decision: 'refused', code: 'agent_session_operation_expired' } + return { + decision: 'refused', + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } } const perClientLimit = args.perClientLimit ?? AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT const globalLimit = args.globalLimit ?? AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT @@ -253,7 +283,11 @@ export function evaluateAgentSessionOperation(args: { if (callerCount >= perClientLimit || rows.size >= globalLimit) { // Why: tombstones cannot be evicted early without making an old replay capable of spawning // again; reject new ids until retained rows age out. - return { decision: 'refused', code: 'agent_session_operation_capacity' } + return { + decision: 'refused', + code: 'agent_session_operation_capacity', + details: { reason: 'operationCapacity' } + } } return { decision: 'admit', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts new file mode 100644 index 00000000000..6080a772ee2 --- /dev/null +++ b/src/shared/agent-session-refusal-details.ts @@ -0,0 +1,271 @@ +// What a refusal says beyond its code: one reason list and one set of facts per code. +// +// A code alone cannot say what to do: `agent_session_operation_invalid` covers a cleared +// conversation (open the current one) and a busy command (wait). Each reason here is one situation +// with its own honest words and next step; a reason two codes share is listed under each. There is +// no catch-all: a site that cannot name its situation sends the code with no reason, the same as an +// older host, and the reader falls back to what it does for the code. + +import type { AgentJournalResolution } from './agent-session-journal-types' +import { isAgentJournalResolution } from './agent-session-journal-schemas' +import { AGENT_SESSION_REWIND_REASONS, type AgentSessionRewindReason } from './agent-session-rewind' +import type { + AgentSessionOwnerVerdict, + AgentSessionWireRefusalCode +} from './agent-session-wire-refusals' + +export const AGENT_SESSION_REFUSAL_REASONS = { + agent_session_operation_invalid: [ + // The request + 'requestMalformed', + 'operationIdInvalid', + 'messageIdReused', + 'operationRefusedEarlier', + 'journalWriteFailed', + // The conversation's state + 'conversationCleared', + /** A /clear that never committed; its replacement conversation may not exist. */ + 'clearUnconfirmed', + 'conversationCommandUnconfirmed', + 'conversationCommandInFlight', + 'handoffInFlight', + 'turnActive', + 'promptPending', + 'backgroundTasksRunning', + 'messagesUnsettled', + 'rewindRefused', + 'rewindUnconfirmed', + // A prompt card or an option + 'promptGone', + 'optionRejected', + // The provider + 'providerStarting', + 'goalsUnsupported', + 'providerRejected', + 'providerStartFailed', + 'notSignedIn', + 'historyTooLarge', + /** The launch's own Anthropic sign-in variables would override the managed Claude account. */ + 'managedAccountEnvOverride', + 'accountSwitchInProgress', + /** A Claude account is added in WSL and no Windows one is selected, which a chat can't run under. */ + 'managedAccountUnsupported', + /** The agent started, then Orca could not open the chat's conversation for it. */ + 'attachFailed' + ], + agent_session_ownership_unknown: [ + 'sessionNotAttached', + 'noLiveOwner', + /** Orca cannot prove an agent process it recorded or started has exited. */ + 'ownerUnproven', + 'claimConflicted', + 'recordMissing', + 'replaySuperseded', + 'leaseMoved', + 'spawnIdentityMismatch', + 'notResumable', + 'noProviderChild', + 'conversationHeldElsewhere' + ], + agent_session_conflict: [ + 'chatStarting', + 'ownerUnproven', + 'claimConflicted', + 'ownerAlive', + 'identityMismatch', + 'sessionExists', + 'conversationHeldElsewhere', + 'tabIdTaken' + ], + execution_owner_reconciling: ['hostReconciling', 'recordUnreadable'], + agent_session_checkpoint_stale: ['fenceStale', 'leaseMoved', 'recordMissing'], + agent_session_identity_required: ['recordMissing', 'transcriptNotFound', 'transcriptUnreadable'], + agent_session_operation_conflict: ['fingerprintMismatch', 'operationIdReused', 'handoffInFlight'], + agent_session_operation_expired: ['operationExpired'], + agent_session_operation_capacity: ['operationCapacity'], + agent_session_operation_unknown: [ + 'outcomeUnknown', + 'resultLost', + 'rewindUnconfirmed', + 'tabUnconfirmed' + ], + agent_session_item_revision_stale: ['promptMoved'], + agent_session_already_resolved: ['promptAlreadyResolved'], + agent_session_journal_unreadable: [ + /** SQLite reports the chat's journal damaged or not a database; no retry reads past it. */ + 'journalCorrupt', + /** Any other failed open, which can clear. */ + 'journalUnavailable' + ], + structured_agent_session_unsupported: [ + 'clientCapabilityMissing', + 'hostDisabled', + 'hostUnsupported' + ], + // Sent with no reason when starting the agent for an operation throws; older clients read it too. + agent_session_owner_restart_failed: [] +} as const satisfies Record + +export type AgentSessionRefusalReason = + (typeof AGENT_SESSION_REFUSAL_REASONS)[C][number] + +/** A reason under any code, for a reader that keys words on the name alone. */ +export type AgentSessionAnyRefusalReason = AgentSessionRefusalReason + +type RewindFacts = { + /** Which rewind check refused. */ + rewindReason?: AgentSessionRewindReason +} + +type PromptFacts = { + /** The revision the host actually holds. */ + currentRevision?: number + /** The winning answer and who gave it. */ + resolution?: AgentJournalResolution +} + +type NoFacts = Record + +/** The facts a code carries beside its reason. */ +type AgentSessionRefusalFactsByCode = { + agent_session_operation_invalid: RewindFacts + agent_session_operation_unknown: RewindFacts + agent_session_checkpoint_stale: { + /** So the client can retry without another round trip. */ + currentFence?: number + } + agent_session_item_revision_stale: PromptFacts + agent_session_already_resolved: PromptFacts + agent_session_ownership_unknown: NoFacts + agent_session_conflict: NoFacts + execution_owner_reconciling: NoFacts + agent_session_identity_required: NoFacts + agent_session_operation_conflict: NoFacts + agent_session_operation_expired: NoFacts + agent_session_operation_capacity: NoFacts + agent_session_journal_unreadable: NoFacts + structured_agent_session_unsupported: NoFacts + agent_session_owner_restart_failed: NoFacts +} + +/** A durably failed create says whether anything still runs for it, whichever code it failed + * with: `exited` proves nothing does, so a new operation cannot collide with this one. */ +type AgentSessionRefusalCommonFacts = { ownerVerdict?: AgentSessionOwnerVerdict } + +export type AgentSessionRefusalFacts = + AgentSessionRefusalFactsByCode[C] & AgentSessionRefusalCommonFacts + +/** What an emitter must name: a reason its code lists, and that code's facts. */ +export type AgentSessionRefusalDetailsByCode = { + [C in AgentSessionWireRefusalCode]: { + reason: AgentSessionRefusalReason + } & AgentSessionRefusalFacts +} + +/** Details as the wire and a durable record carry them. The reason is absent where the host could + * not name the situation; the whole object is absent from older hosts. */ +export type AgentSessionRefusalDetails = { + reason?: AgentSessionRefusalReason +} & AgentSessionRefusalFacts + +/** Details under whichever code, for a store that keeps them beside a code it reads as a string. */ +export type AgentSessionAnyRefusalDetails = AgentSessionRefusalDetails + +/** The loose top-level fields released clients read; the host writes them only from details. */ +export type AgentSessionLegacyRefusalFields = { + currentFence?: number + currentRevision?: number + resolution?: AgentJournalResolution + ownerVerdict?: AgentSessionOwnerVerdict + rewindReason?: AgentSessionRewindReason +} + +const FACT_KEYS_BY_CODE: Partial< + Record +> = { + agent_session_operation_invalid: ['rewindReason'], + agent_session_operation_unknown: ['rewindReason'], + agent_session_checkpoint_stale: ['currentFence'], + agent_session_item_revision_stale: ['currentRevision', 'resolution'], + agent_session_already_resolved: ['currentRevision', 'resolution'] +} + +const OWNER_VERDICTS: readonly AgentSessionOwnerVerdict[] = ['live', 'unverifiable', 'exited'] + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readFact( + key: keyof AgentSessionLegacyRefusalFields, + value: unknown +): AgentSessionLegacyRefusalFields { + switch (key) { + case 'currentFence': + return Number.isSafeInteger(value) && typeof value === 'number' ? { currentFence: value } : {} + case 'currentRevision': + return Number.isSafeInteger(value) && typeof value === 'number' + ? { currentRevision: value } + : {} + case 'resolution': + return isAgentJournalResolution(value) ? { resolution: value } : {} + case 'ownerVerdict': { + const ownerVerdict = OWNER_VERDICTS.find((verdict) => verdict === value) + return ownerVerdict ? { ownerVerdict } : {} + } + case 'rewindReason': { + const rewindReason = AGENT_SESSION_REWIND_REASONS.find((reason) => reason === value) + return rewindReason ? { rewindReason } : {} + } + } +} + +export function isAgentSessionRefusalReason( + code: C, + value: unknown +): value is AgentSessionRefusalReason { + const reasons: readonly string[] = AGENT_SESSION_REFUSAL_REASONS[code] + return typeof value === 'string' && reasons.includes(value) +} + +/** + * Details as a reader meets them: from the wire, a ledger row or a stored failure, possibly + * written by another build. Keeps only a reason and facts this code lists, so a reason a newer + * host added reads as none; undefined when nothing is left. + */ +export function readAgentSessionRefusalDetails( + code: C, + value: unknown +): AgentSessionRefusalDetails | undefined { + if (!isRecord(value)) { + return undefined + } + const keys = [...(FACT_KEYS_BY_CODE[code] ?? []), 'ownerVerdict' as const] + const facts = keys.reduce( + (kept, key) => ({ ...kept, ...readFact(key, value[key]) }), + {} + ) + const read = { + ...(isAgentSessionRefusalReason(code, value.reason) ? { reason: value.reason } : {}), + ...facts + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reason was checked against `code`'s list and only the facts `code` lists (plus the verdict every code may carry) were kept. + return Object.keys(read).length > 0 ? (read as AgentSessionRefusalDetails) : undefined +} + +/** The loose fields released clients read, taken from details; the one place they are written. */ +export function agentSessionLegacyRefusalFields( + details: AgentSessionLegacyRefusalFields | undefined +): AgentSessionLegacyRefusalFields { + if (!details) { + return {} + } + const { currentFence, currentRevision, resolution, ownerVerdict, rewindReason } = details + return { + ...(rewindReason !== undefined ? { rewindReason } : {}), + ...(currentFence !== undefined ? { currentFence } : {}), + ...(resolution !== undefined ? { resolution } : {}), + ...(currentRevision !== undefined ? { currentRevision } : {}), + ...(ownerVerdict !== undefined ? { ownerVerdict } : {}) + } +} diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 1c0a60329e1..a83a95c4388 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -4,17 +4,28 @@ import { type AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { + agentSessionReadHistoryRefusalParts, + agentSessionRefusalCauseParts, agentSessionRefusalNotice, - agentSessionRpcErrorFailure, + agentSessionRefusalReasonWords, agentSessionWriteFailureNotice, - agentSessionWriteKindForMethod, agentSessionWriteNoticeEnglish, - agentSessionWriteNoticeParts, - parseAgentSessionWriteFailure, + agentSessionWriteNoticeParts +} from './agent-session-refusal-notice' +import { + AGENT_SESSION_WRITE_NOTICE_COPY, + type AgentSessionWriteNoticeSentence +} from './agent-session-write-notice-copy' +import { AGENT_SESSION_REFUSAL_REASONS } from './agent-session-refusal-details' +import { agentSessionFailureSentence } from './agent-session-failure-words' +import { + agentSessionRefusalFailure, + agentSessionRpcErrorFailure, + agentSessionWriteKindForMethod, type AgentSessionWriteFailure, type AgentSessionWriteKind, - type AgentSessionWriteNoticeSentence -} from './agent-session-refusal-notice' + type AgentSessionWriteRefusal +} from './agent-session-write-failure' import { DISPATCH_REJECTED_NOT_DELIVERED, DISPATCH_REJECTED_QUEUE_FULL, @@ -23,6 +34,7 @@ import { import { structuredAgentSessionRejectionParts } from './structured-agent-session-send-disposition' const WRITES: AgentSessionWriteKind[] = [ + 'read-history', 'send', 'composer-send', 'stop', @@ -48,7 +60,7 @@ const CAUSES: Partial = { + 'read-history': 'notDoneReadHistory', send: 'notDoneSend', 'composer-send': 'notDoneSend', stop: 'notDoneStop', @@ -97,6 +110,7 @@ function causeSaysNotDone( const code = codeOf(failure) return ( code === 'structured_agent_session_unsupported' || + (write === 'read-history' && code === 'agent_session_journal_unreadable') || (write === 'answer' && (code === 'agent_session_item_revision_stale' || code === 'agent_session_already_resolved')) ) @@ -179,11 +193,12 @@ describe('the notice for every failure and write', () => { // - checkpoint_stale, conflict, identity_required, execution_owner_reconciling, unsupported: the // bare code thrown as the message (lease-release, reservation-admission, tab-table, // claim-identity, lease-transitions, reveal) - // - ownership_unknown: "The session attached without a provider child to write to." (holds) + // - ownership_unknown: "The session attached without a provider child to write to." (agent-start) // - item_revision_stale / already_resolved: "Item has moved on." (prompt-state) // - owner_restart_failed: " couldn't restart: .", where the cause is the resume's - // own refusal message, including the ledger's (send-preparation, hold-resume) - // - journal_unreadable: no emitter on this host; an older or newer one may send it. + // own refusal message, including the ledger's (dead-generation-settlement, agent-start) + // - journal_unreadable: "The conversation could not be opened: " (send preparation, before + // it named a reason) it('is never empty and never shows the host message', () => { for (const { failure, write, parts, cell } of cells) { expect(parts.length, cell).toBeGreaterThan(0) @@ -303,30 +318,328 @@ describe('agentSessionRefusalNotice', () => { }) }) -describe('parseAgentSessionWriteFailure', () => { - it('reads back what was saved, and nothing but the code', () => { +// Every (code x reason) a host can name, as a write keeps it. +const REASONED: AgentSessionWriteRefusal[] = AGENT_SESSION_WIRE_REFUSAL_CODES.flatMap((code) => + AGENT_SESSION_REFUSAL_REASONS[code].map((reason) => + agentSessionRefusalFailure({ code, details: { reason } }) + ) +) + +function reasonOf(failure: AgentSessionWriteRefusal): string { + return `${failure.code}/${failure.details?.reason}` +} + +describe('the notice for every reason a host names', () => { + const cells = REASONED.flatMap((failure) => + WRITES.map((write) => ({ + failure, + write, + parts: agentSessionWriteNoticeParts(failure, write), + words: agentSessionRefusalReasonWords(failure), + cell: `${reasonOf(failure)} x ${write}` + })) + ) + + it('has words for every reason', () => { + for (const { words, cell } of cells) { + expect(words, cell).toBeDefined() + } + }) + + it('keeps the code words where the table says the reason means nothing more', () => { + for (const { failure, write, parts, words, cell } of cells) { + if (words && 'words' in words) { + expect(parts, cell).toEqual( + agentSessionWriteNoticeParts({ ...failure, details: undefined }, write) + ) + } + } + }) + + // A retry is the control that sent the write; only an open that can clear says to try again. + it('names a step exactly where the person has one to take', () => { + for (const { words, cell } of cells) { + if (words && 'cause' in words) { + const retryNow = words.action === 'retry' && words.step === 'tryAgain' + expect(words.step !== undefined && !retryNow, cell).toBe( + words.action === 'wait' || words.action === 'actFirst' || words.action === 'goElsewhere' + ) + expect(retryNow, cell).toBe(words.cause === 'historyUnavailable') + } + } + }) + + // Beside a Retry, that Retry is the step for a reason whose action is to retry, and only for it. + it('leaves out only a retry step when a Retry stands beside the words', () => { + const beside = { agentName: 'Codex', retryControl: true } + for (const { failure, write, parts, words, cell } of cells) { + if (words && 'cause' in words) { + const retried = words.action === 'retry' ? words.step : undefined + expect(agentSessionWriteNoticeParts(failure, write, beside), cell).toEqual( + parts.filter((part) => part !== retried) + ) + } + } + const send = (failure: AgentSessionWriteRefusal) => + agentSessionWriteNoticeParts(failure, 'send', beside) expect( - parseAgentSessionWriteFailure( - JSON.parse(JSON.stringify({ kind: 'refused', code: 'agent_session_conflict' })) - ) - ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) - expect(parseAgentSessionWriteFailure({ kind: 'failed' })).toEqual({ kind: 'failed' }) - expect( - parseAgentSessionWriteFailure({ + send({ kind: 'refused', - code: 'agent_session_conflict', - message: HOST_TEXT + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } }) - ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) + ).toEqual(['historyUnavailable', 'notDoneSend']) + expect( + send({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }) + ).toEqual([ + 'notDoneSend', + { text: 'Codex is not signed in for the selected account. Sign in first.' } + ]) + expect( + send({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'providerStartFailed' } + }) + ).toEqual(['notDoneSend', { text: 'Codex stopped before it finished starting.' }]) + }) + + it('says the write did not happen, once, and never shows the host message', () => { + for (const { failure, write, parts, cell } of cells) { + const english = agentSessionWriteNoticeEnglish(parts) + expect(english.length, cell).toBeGreaterThan(0) + expect(agentSessionRefusalNotice({ ...failure, message: HOST_TEXT }, write), cell).toBe( + english + ) + if (failure.code === 'agent_session_operation_unknown') { + expect(parts, cell).toEqual(['outcomeUnknown']) + continue + } + const notDone = parts.filter((part) => typeof part === 'string' && part.startsWith('notDone')) + const answeredAway = write === 'answer' && parts.includes('questionChanged') + const unsupported = failure.code === 'structured_agent_session_unsupported' + const saysNotDone = + write === 'read-history' && failure.code === 'agent_session_journal_unreadable' + expect(notDone, cell).toEqual( + answeredAway || unsupported || saysNotDone ? [] : [NOT_DONE[write]] + ) + } + }) + + it("uses the failure's own sentence for a start that failed, and only for a message", () => { + const failure = agentSessionRefusalFailure({ + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }) + expect(agentSessionWriteNoticeParts(failure, 'send')).toEqual([ + 'notDoneSend', + { text: agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection') } + ]) + // Its next step is to send the message again, which a Stop cannot do. + expect(agentSessionWriteNoticeParts(failure, 'stop')).toEqual(['notDoneStop']) }) it.each([ - null, - 'The agent was restarting.', - { kind: 'refused' }, - { kind: 'refused', code: 'agent_session_from_the_future' }, - { kind: 'something-else' } - ])('drops %j instead of guessing', (value) => { - expect(parseAgentSessionWriteFailure(value)).toBeUndefined() + [ + 'agent_session_operation_invalid', + 'conversationCleared', + 'send', + 'This conversation has been cleared. Your message was not sent. Open the current conversation to continue.' + ], + [ + 'agent_session_operation_invalid', + 'clearUnconfirmed', + 'send', + "The last /clear didn't finish. Your message was not sent. Start a new chat to continue." + ], + [ + 'agent_session_operation_invalid', + 'turnActive', + 'command', + "The agent is still responding. The command didn't run. Wait for the agent to finish responding, or stop it." + ], + [ + 'agent_session_conflict', + 'claimConflicted', + 'composer-send', + 'This chat is still open in a terminal agent. Your message was not sent. Quit that agent to continue the chat here.' + ], + [ + 'agent_session_conflict', + 'chatStarting', + 'stop', + "The agent is still starting. The agent wasn't stopped. Wait for the agent to finish starting." + ], + [ + 'agent_session_operation_invalid', + 'promptGone', + 'answer', + 'This question was already answered or has changed.' + ], + [ + 'agent_session_checkpoint_stale', + 'fenceStale', + 'composer-send', + 'Your message was not sent. Send it again.' + ] + ] as const)('%s / %s on %s', (code, reason, write, expected) => { + expect( + agentSessionRefusalNotice({ code, message: HOST_TEXT, details: { reason } }, write) + ).toBe(expected) + }) +}) + +describe('a refusal from a host that names no reason this build knows', () => { + it.each([ + ['an older host', undefined], + ['a reason a newer host added', { reason: 'fromTheFuture' }], + ['facts with no reason', { ownerVerdict: 'unverifiable' }] + ])("reads as the code's row: %s", (_label, details) => { + const refusal = JSON.parse( + JSON.stringify({ code: 'agent_session_operation_invalid', message: HOST_TEXT, details }) + ) + expect(agentSessionRefusalNotice(refusal, 'command')).toBe("The command didn't run.") + }) +}) + +it('keeps a sentence the failure rows share word for word', () => { + expect(agentSessionFailureSentence({ kind: 'historyTooLarge' }, 'row')).toContain( + AGENT_SESSION_WRITE_NOTICE_COPY.startNewChat + ) +}) + +// A failed journal open says what happened to the history, not what was asked of it. +describe('a chat whose history the host could not open', () => { + const notice = (details: unknown, write: AgentSessionWriteKind): string => + agentSessionRefusalNotice( + JSON.parse( + JSON.stringify({ code: 'agent_session_journal_unreadable', message: HOST_TEXT, details }) + ), + write + ) + + it.each([ + ['journalCorrupt', 'read-history', 'Unable to load this chat.'], + ['journalCorrupt', 'send', 'Unable to load this chat. Your message was not sent.'], + [ + 'journalUnavailable', + 'read-history', + "Orca couldn't open this chat's history right now. Try again." + ], + [ + 'journalUnavailable', + 'send', + "Orca couldn't open this chat's history right now. Your message was not sent. Try again." + ] + ] as const)('%s on %s', (reason, write, expected) => { + expect(notice({ reason }, write)).toBe(expected) + }) + + // Damage can't be told apart from an open that can clear, so it never says to start over. + it.each([ + ['an older host', undefined], + ['a reason an unreleased build wrote', { reason: 'journalUnreadable' }] + ])('promises nothing for a refusal that names no reason: %s', (_label, details) => { + expect(notice(details, 'read-history')).toBe("Orca couldn't read this chat's saved history.") + expect(notice(details, 'send')).toBe( + "Orca couldn't read this chat's saved history. Your message was not sent." + ) + expect( + agentSessionReadHistoryRefusalParts('agent_session_journal_unreadable', details) + ).toEqual(['historyUnreadable']) + }) + + it('says only that the history did not load for any other read refusal', () => { + expect(agentSessionReadHistoryRefusalParts('agent_session_ownership_unknown')).toEqual([ + 'notDoneReadHistory' + ]) + expect(agentSessionReadHistoryRefusalParts('agent_session_from_the_future')).toEqual([ + 'notDoneReadHistory' + ]) + }) +}) + +// For a line that already says what did not happen and shows its own Retry, such as a chat that +// could not start: the Retry is the step for retrying and for sending again, and only for those. +describe('agentSessionRefusalCauseParts', () => { + it('keeps a step other than retrying, beside the cause', () => { + expect( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'turnActive' } + }) + ).toEqual(['turnActive', 'waitForTurn']) + expect( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'historyTooLarge' } + }) + ).toEqual([ + { + text: "This conversation's history is too large to restore here. Start a new chat to continue." + } + ]) + }) + + it('leaves out a step the Retry beside it takes', () => { + expect( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + }) + ).toEqual(['historyUnavailable']) + expect( + agentSessionRefusalCauseParts( + { + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'notSignedIn' } + }, + { agentName: 'Claude' } + ) + ).toEqual([{ text: 'Claude is not signed in for the selected account. Sign in first.' }]) + expect( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'providerStartFailed' } + }) + ).toEqual([{ text: 'The agent stopped before it finished starting.' }]) + expect( + agentSessionRefusalCauseParts({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'managedAccountUnsupported' } + }) + ).toEqual([ + { + text: 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings.' + } + ]) + }) + + it('writes the same sentences as before where no Retry stands beside them', () => { + expect(agentSessionFailureSentence({ kind: 'notSignedIn' }, 'rejection')).toBe( + 'The agent is not signed in for the selected account. Sign in, then send your message again.' + ) + }) + + it.each<[string, AgentSessionWriteFailure]>([ + ['a refusal with no reason', { kind: 'refused', code: 'agent_session_operation_invalid' }], + [ + "a reason whose code's words stand", + { kind: 'refused', code: 'agent_session_conflict', details: { reason: 'ownerAlive' } } + ], + ['a failed request', { kind: 'failed' }], + ['an unconfirmed one', { kind: 'unconfirmed' }] + ])('names nothing for %s', (_label, failure) => { + expect(agentSessionRefusalCauseParts(failure)).toEqual([]) }) }) diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 4b3db2be0a6..0f4121dc9b3 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -1,127 +1,40 @@ // The one way a chat surface puts a write that did not happen into words. // // A refusal's `message` is never shown. Every code has at least one host emitter whose message is -// written for a log or carries a marker (the census is pinned in the test), so the code and the -// write pick the copy. A cause is named only where every emitter of the code means it. No notice -// says how to try again: the control that sent the write does that, except on the phone, whose -// message goes back to the composer. Surfaces keep the fact and choose the words when they show -// it, so nothing saved carries copy. +// written for a log or carries a marker (the census is pinned in the test), so the refusal's +// reason, when the host names one, and otherwise its code, pick the copy with the write. A code's +// own row names a cause only where every emitter of the code means it; it is also the words for a +// host too old to send a reason. A notice says how to get past a refusal only where the person has +// a step to take; retrying is the control that sent the write, except on the phone, whose message +// goes back to the composer, and a history that couldn't open right now says to try again unless +// something beside the words retries it. +// Surfaces keep the fact and choose the words when they show it, so nothing saved carries copy. +import type { AgentSessionFailureKind } from './agent-session-failure' import { - isAgentSessionWireRefusalCode, - type AgentSessionWireRefusal, - type AgentSessionWireRefusalCode + agentSessionFailureSentence, + type AgentSessionFailureWordsContext +} from './agent-session-failure-words' +import type { AgentSessionRefusalReason } from './agent-session-refusal-details' +import { + AGENT_SESSION_WRITE_NOTICE_COPY, + type AgentSessionWriteNoticePart, + type AgentSessionWriteNoticeSentence +} from './agent-session-write-notice-copy' +import type { + AgentSessionWireRefusal, + AgentSessionWireRefusalCode } from './agent-session-wire-refusals' - -/** What the person was doing, which decides what the notice says did not happen. `send` keeps - * the message behind a Retry control; `composer-send` puts it back in the composer, as the phone - * does. */ -export type AgentSessionWriteKind = - | 'send' - | 'composer-send' - | 'stop' - | 'stop-task' - | 'stop-tasks' - | 'answer' - | 'option' - | 'command' - | 'goal' - -/** The kind of write an `agentSession.*` call stands for. */ -export function agentSessionWriteKindForMethod( - fingerprintMethod: string, - fields: Record -): AgentSessionWriteKind { - if (fingerprintMethod === 'agentSession.send') { - return 'send' - } - if (fingerprintMethod === 'agentSession.cancel') { - // A background-task stop never asked the agent to stop. - if (fields.scope !== 'background-tasks') { - return 'stop' - } - return typeof fields.taskId === 'string' ? 'stop-task' : 'stop-tasks' - } - if (fingerprintMethod.startsWith('agentSession.respondTo')) { - return 'answer' - } - if (fingerprintMethod === 'agentSession.setOption') { - return 'option' - } - if (fingerprintMethod === 'agentSession.threadGoal') { - return 'goal' - } - return 'command' -} - -/** Why a write did not happen, or that nothing proves it did not. */ -export type AgentSessionWriteFailure = - | { kind: 'refused'; code: AgentSessionWireRefusalCode } - /** The request failed without a refusal before the host ran it, so nothing is known about why. */ - | { kind: 'failed' } - /** The request failed where the host may already have run it (a timeout, a lost connection, an - * error inside the method). */ - | { kind: 'unconfirmed' } - -export function agentSessionRefusalFailure( - refusal: Pick -): AgentSessionWriteFailure { - return { kind: 'refused', code: refusal.code } -} - -/** A request that threw, from the RPC error code the host answered with (undefined when none came - * back). Only a host that turned it away before running the method proves the write did not - * happen. */ -export function agentSessionRpcErrorFailure(code: string | undefined): AgentSessionWriteFailure { - if (code === 'method_not_found' || code === 'method_not_supported') { - return { kind: 'refused', code: 'structured_agent_session_unsupported' } - } - return code === 'invalid_argument' || code === 'unauthorized' - ? { kind: 'refused', code: 'agent_session_operation_invalid' } - : { kind: 'unconfirmed' } -} - -/** A saved failure, or undefined when it is not one this build wrote. */ -export function parseAgentSessionWriteFailure( - value: unknown -): AgentSessionWriteFailure | undefined { - if (typeof value !== 'object' || value === null || !('kind' in value)) { - return undefined - } - if (value.kind === 'failed') { - return { kind: 'failed' } - } - return value.kind === 'refused' && 'code' in value && isAgentSessionWireRefusalCode(value.code) - ? { kind: 'refused', code: value.code } - : undefined -} - -/** Every sentence a notice is made of. Desktop translates each whole sentence with this as its - * fallback; mobile shows it as is. */ -export const AGENT_SESSION_WRITE_NOTICE_COPY = { - notDoneSend: 'Your message was not sent.', - tryAgainComposerSend: 'Send it again.', - notDoneStop: "The agent wasn't stopped.", - notDoneStopTask: "The background task wasn't stopped.", - notDoneStopTasks: "The background tasks weren't stopped.", - notDoneAnswer: 'Your answer was not sent.', - notDoneOption: "The setting wasn't changed.", - notDoneCommand: "The command didn't run.", - notDoneGoal: "The goal wasn't changed.", - restartFailed: "The agent couldn't restart.", - capacity: 'Orca has received too many requests in the last day.', - outcomeUnknown: "Orca couldn't confirm what happened. Check the chat.", - questionChanged: 'This question was already answered or has changed.', - historyUnreadable: "Orca couldn't read this chat's saved history.", - unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", - unreachable: "Orca couldn't reach the agent." -} as const - -export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY -/** A notice as whole sentences, each translated on its own; `text` is a provider's own words. */ -export type AgentSessionWriteNoticePart = AgentSessionWriteNoticeSentence | { text: string } +import { + agentSessionRefusalFailure, + parseAgentSessionWriteFailure, + type AgentSessionWriteFailure, + type AgentSessionWriteKind, + type AgentSessionWriteRefusal +} from './agent-session-write-failure' const NOT_DONE: Record = { + 'read-history': 'notDoneReadHistory', send: 'notDoneSend', 'composer-send': 'notDoneSend', stop: 'notDoneStop', @@ -142,9 +55,236 @@ export function agentSessionWriteNotDoneParts( return write === 'composer-send' ? ['notDoneSend', 'tryAgainComposerSend'] : [NOT_DONE[write]] } +/** What the person can do about a refusal with this reason. */ +export type AgentSessionRefusalAction = + /** Use the control that sent the write again. */ + | 'retry' + /** Wait for what the notice names; its step says what. */ + | 'wait' + /** Take the step the notice names first. */ + | 'actFirst' + /** Continue somewhere else: the current conversation, or a new chat. */ + | 'goElsewhere' + | 'checkChat' + | 'updateOrca' + /** What the write was for is over: the question it answered moved on. */ + | 'nothingLeft' + /** Orca's own state or fault; nothing the person does gets past it. */ + | 'hostFinding' + +export type AgentSessionRefusalReasonWords = + /** The code's own words are the honest ones for this reason. */ + | { words: 'code'; action: AgentSessionRefusalAction } + /** What stopped the write, and the step past it where the person has one to take. */ + | { + cause: AgentSessionWriteNoticeSentence + step?: AgentSessionWriteNoticeSentence + action: AgentSessionRefusalAction + } + /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ + | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } + +function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { + return { words: 'code', action } +} + +function causeWords( + cause: AgentSessionWriteNoticeSentence, + action: AgentSessionRefusalAction, + step?: AgentSessionWriteNoticeSentence +): AgentSessionRefusalReasonWords { + return step ? { cause, step, action } : { cause, action } +} + +const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') +const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') +// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. +const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') + +// Every reason of every code, so a reason the host adds does not compile until it has words. +// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep +// their code's words. +const REASON_WORDS = { + agent_session_operation_invalid: { + requestMalformed: codeWords('hostFinding'), + operationIdInvalid: codeWords('hostFinding'), + messageIdReused: codeWords('hostFinding'), + // Settled under that id, so the control's retry goes out under a new one. + operationRefusedEarlier: codeWords('retry'), + journalWriteFailed: causeWords('recordFailed', 'retry'), + conversationCleared: causeWords( + 'conversationCleared', + 'goElsewhere', + 'openCurrentConversation' + ), + // Nothing settles an unfinished /clear yet, so only a new chat continues. + clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), + // A /clear or /compact whose outcome the host never settled; only the host resolves it. + conversationCommandUnconfirmed: codeWords('hostFinding'), + conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), + // The chat's agent process is being replaced, which a start or restart does. + handoffInFlight: AGENT_STARTING, + turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), + promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), + backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), + messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + // No chat surface sends a rewind; a replayed one says only that it did not happen. + rewindRefused: codeWords('hostFinding'), + rewindUnconfirmed: codeWords('hostFinding'), + promptGone: causeWords('questionChanged', 'nothingLeft'), + optionRejected: causeWords('optionRejected', 'retry'), + providerStarting: AGENT_STARTING, + goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), + providerRejected: causeWords('agentRefused', 'retry'), + providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, + notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, + historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, + managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, + accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, + managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, + attachFailed: codeWords('retry') + }, + agent_session_ownership_unknown: { + sessionNotAttached: codeWords('retry'), + noLiveOwner: codeWords('retry'), + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + recordMissing: codeWords('retry'), + replaySuperseded: codeWords('retry'), + leaseMoved: codeWords('retry'), + spawnIdentityMismatch: codeWords('hostFinding'), + notResumable: codeWords('retry'), + noProviderChild: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry') + }, + agent_session_conflict: { + chatStarting: AGENT_STARTING, + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + ownerAlive: codeWords('retry'), + identityMismatch: codeWords('hostFinding'), + sessionExists: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + tabIdTaken: codeWords('retry') + }, + execution_owner_reconciling: { + hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), + recordUnreadable: causeWords('recordUnreadable', 'hostFinding') + }, + agent_session_checkpoint_stale: { + fenceStale: codeWords('retry'), + leaseMoved: codeWords('retry'), + recordMissing: codeWords('retry') + }, + agent_session_identity_required: { + recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), + transcriptNotFound: codeWords('retry'), + transcriptUnreadable: codeWords('hostFinding') + }, + agent_session_operation_conflict: { + fingerprintMismatch: codeWords('hostFinding'), + operationIdReused: codeWords('hostFinding'), + handoffInFlight: codeWords('retry') + }, + agent_session_operation_expired: { operationExpired: codeWords('retry') }, + agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, + agent_session_operation_unknown: { + outcomeUnknown: codeWords('checkChat'), + resultLost: codeWords('checkChat'), + rewindUnconfirmed: codeWords('checkChat'), + tabUnconfirmed: codeWords('checkChat') + }, + agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, + agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, + agent_session_journal_unreadable: { + // No retry reads past damage, and the words name no step: it only can't load. + journalCorrupt: causeWords('historyUnusable', 'hostFinding'), + // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain') + }, + // Thrown, so a client meets these only as an RPC error; the code's words stand. + structured_agent_session_unsupported: { + clientCapabilityMissing: codeWords('updateOrca'), + hostDisabled: codeWords('hostFinding'), + hostUnsupported: codeWords('updateOrca') + }, + agent_session_owner_restart_failed: {} +} satisfies { + [C in AgentSessionWireRefusalCode]: Record< + AgentSessionRefusalReason, + AgentSessionRefusalReasonWords + > +} + +/** The words and next step a reason gets; undefined when the refusal names none. */ +export function agentSessionRefusalReasonWords( + failure: AgentSessionWriteRefusal +): AgentSessionRefusalReasonWords | undefined { + const reason = failure.details?.reason + const byReason: Partial> | undefined = + REASON_WORDS[failure.code] + return reason === undefined ? undefined : byReason?.[reason] +} + +// Each already says the history was not read. +const HISTORY_CAUSES: ReadonlySet = new Set([ + 'historyUnusable', + 'historyUnavailable', + 'historyUnreadable' +]) + +/** A cause, and that the request did not happen unless the cause already says so. */ +function causeParts( + cause: AgentSessionWriteNoticeSentence, + write: AgentSessionWriteKind +): AgentSessionWriteNoticeSentence[] { + const saysNotDone = + (write === 'read-history' && HISTORY_CAUSES.has(cause)) || + (cause === 'questionChanged' && write === 'answer') + return saysNotDone ? [cause] : [cause, NOT_DONE[write]] +} + +/** The notice a named reason has of its own; undefined leaves the code's words. */ +function reasonParts( + failure: AgentSessionWriteRefusal, + write: AgentSessionWriteKind, + context: AgentSessionFailureWordsContext +): AgentSessionWriteNoticePart[] | undefined { + const words = agentSessionRefusalReasonWords(failure) + if (!words || 'words' in words) { + return undefined + } + if ('fact' in words) { + const sentence = agentSessionFailureSentence({ kind: words.fact }, 'rejection', context) + return write === 'send' || write === 'composer-send' + ? [NOT_DONE[write], { text: sentence }] + : undefined + } + const said = causeParts(words.cause, write) + // A Retry beside the words is the step for a reason whose action is to retry. + const retried = context.retryControl && words.action === 'retry' + return words.step && !retried ? [...said, words.step] : said +} + +/** What stopped a refused start, for a line that already says the chat did not start and shows its + * own Retry. Empty when the refusal names no reason with words of its own. */ +export function agentSessionRefusalCauseParts( + failure: AgentSessionWriteFailure, + context: { agentName?: string } = {} +): AgentSessionWriteNoticePart[] { + const parts = + failure.kind === 'refused' + ? reasonParts(failure, 'send', { ...context, retryControl: true }) + : undefined + return parts?.filter((part) => part !== 'notDoneSend') ?? [] +} + +/** `context.retryControl`: a Retry beside the words is the step for a reason whose action is to + * retry, and for sending again; any other step stays. */ export function agentSessionWriteNoticeParts( failure: AgentSessionWriteFailure, - write: AgentSessionWriteKind + write: AgentSessionWriteKind, + context: AgentSessionFailureWordsContext = {} ): AgentSessionWriteNoticePart[] { const notDone = NOT_DONE[write] if (failure.kind === 'failed') { @@ -153,6 +293,10 @@ export function agentSessionWriteNoticeParts( if (failure.kind === 'unconfirmed') { return ['outcomeUnknown'] } + const byReason = reasonParts(failure, write, context) + if (byReason) { + return byReason + } switch (failure.code) { // The cause is in the chat's own status row. Some restarts can be retried and some need a new // chat, and the code does not say which. @@ -182,9 +326,10 @@ export function agentSessionWriteNoticeParts( // A Stop names the prompt it was pressed under, so it can be refused this way too. case 'agent_session_item_revision_stale': case 'agent_session_already_resolved': - return write === 'answer' ? ['questionChanged'] : ['questionChanged', notDone] + return causeParts('questionChanged', write) + // A host that names no reason raised it for damage and for an open that can clear alike. case 'agent_session_journal_unreadable': - return ['historyUnreadable', notDone] + return causeParts('historyUnreadable', write) case 'structured_agent_session_unsupported': return ['unsupported'] } @@ -203,7 +348,7 @@ export function agentSessionWriteNoticeEnglish( /** English, for a surface without translations. Takes the refusal as the wire gives it; its * message is not read. */ export function agentSessionRefusalNotice( - refusal: Pick, + refusal: Pick, write: AgentSessionWriteKind ): string { return agentSessionWriteNoticeEnglish( @@ -215,3 +360,16 @@ export function agentSessionRefusalNotice( export function agentSessionWriteFailureNotice(write: AgentSessionWriteKind): string { return agentSessionWriteNoticeEnglish(agentSessionWriteNoticeParts({ kind: 'failed' }, write)) } + +/** The notice for a refused read of a chat's history, from the refusal's code and any details a + * host sent; a code this build does not know says only that the history did not load. */ +export function agentSessionReadHistoryRefusalParts( + code: string, + details?: unknown, + context: AgentSessionFailureWordsContext = {} +): AgentSessionWriteNoticePart[] { + const failure = parseAgentSessionWriteFailure({ kind: 'refused', code, details }) + return failure + ? agentSessionWriteNoticeParts(failure, 'read-history', context) + : agentSessionWriteNotDoneParts('read-history') +} diff --git a/src/shared/agent-session-refusal-retry.test.ts b/src/shared/agent-session-refusal-retry.test.ts new file mode 100644 index 00000000000..30a9a177f8e --- /dev/null +++ b/src/shared/agent-session-refusal-retry.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionOwnerVerdict } from './agent-session-wire' +import { agentSessionOwnerVerdictAllowsFreshOperationId } from './agent-session-refusal-retry' +import { parseAgentSessionWriteFailure } from './agent-session-write-failure' +import { + admitStructuredAgentSessionOutboxEntry, + createStructuredAgentSessionOutboxEntry, + requeueStructuredAgentSessionSendRefusal +} from './structured-agent-session-outbox' +import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' + +const entry = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'message-1', + sessionId: 'session-1', + text: 'hello', + attachments: [], + queuedAt: 1 +}) + +/** A refused send as a reloaded entry holds it: through the saved shape and back. */ +function storedOwnershipRefusal(ownerVerdict: AgentSessionOwnerVerdict | undefined) { + const failure = parseAgentSessionWriteFailure( + JSON.parse( + JSON.stringify({ + kind: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven', ...(ownerVerdict ? { ownerVerdict } : {}) } + }) + ) + ) + if (failure?.kind !== 'refused') { + throw new Error('the saved refusal did not read back') + } + return failure +} + +function retried(ownerVerdict: AgentSessionOwnerVerdict | undefined) { + return requeueStructuredAgentSessionSendRefusal( + entry, + storedOwnershipRefusal(ownerVerdict), + () => 'message-2' + ) +} + +describe("the owner's verdict decides a retry's operation id only as a floor", () => { + it('lets a retry use a new id once the stored verdict proves nothing runs', () => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited')).toBe(true) + // A new id only: nothing recorded the message, so it stays queued at the head. + expect(retried('exited')).toMatchObject({ clientMessageId: 'message-2', state: 'queued' }) + }) + + it('keeps later sends behind the message after an exited refusal', () => { + const later = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'message-3', + sessionId: 'session-1', + text: 'after', + attachments: [], + queuedAt: 2 + }) + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry, later], + entry, + blockedClientMessageId: null, + result: { + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + message: 'x', + details: { reason: 'ownerUnproven', ownerVerdict: 'exited' } + } + }, + createOperationId: () => 'message-2' + }) + expect(disposition.blockedClientMessageId).toBe('message-2') + expect( + admitStructuredAgentSessionOutboxEntry( + disposition.entries, + disposition.blockedClientMessageId + ) + ).toMatchObject({ state: 'blocked', entry: { clientMessageId: 'message-2' } }) + }) + + it.each(['unverifiable', 'live', undefined])( + 'keeps the id for a stored %s verdict, whose operation may still land', + (stored) => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored)).toBe(false) + expect(retried(stored)).toMatchObject({ clientMessageId: 'message-1', state: 'queued' }) + } + ) + + it.each(['unverifiable', 'live', undefined])( + 'moves off a stored %s verdict only when the current lease proves the owner exited', + (stored) => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'exited')).toBe(true) + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'unverifiable')).toBe(false) + expect(agentSessionOwnerVerdictAllowsFreshOperationId(stored, 'live')).toBe(false) + } + ) + + it('never lowers a stored exited', () => { + expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited', 'live')).toBe(true) + expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited', 'unverifiable')).toBe(true) + }) +}) diff --git a/src/shared/agent-session-refusal-retry.ts b/src/shared/agent-session-refusal-retry.ts index 9777cfd42d1..093d3549729 100644 --- a/src/shared/agent-session-refusal-retry.ts +++ b/src/shared/agent-session-refusal-retry.ts @@ -1,4 +1,4 @@ -import type { AgentSessionWireRefusalCode } from './agent-session-wire' +import type { AgentSessionOwnerVerdict, AgentSessionWireRefusalCode } from './agent-session-wire' export type AgentSessionRefusalOperationState = 'settled-rejected' | 'pending-admission' | 'unknown' @@ -28,3 +28,16 @@ export function agentSessionRefusalOperationState( return 'pending-admission' } } + +/** + * Whether a verdict lets a retry use a new operation id. A stored `exited` is final: nothing runs + * the refused operation, so a new id cannot collide with it. Any other stored verdict is a floor: + * the first operation may still land, so only a verdict re-derived from the current lease can prove + * `exited`, and nothing lowers a stored `exited`. + */ +export function agentSessionOwnerVerdictAllowsFreshOperationId( + stored: AgentSessionOwnerVerdict | undefined, + current?: AgentSessionOwnerVerdict +): boolean { + return stored === 'exited' || current === 'exited' +} diff --git a/src/shared/agent-session-resume-marker.ts b/src/shared/agent-session-resume-marker.ts index 2c308ec998e..3890bd8f81d 100644 --- a/src/shared/agent-session-resume-marker.ts +++ b/src/shared/agent-session-resume-marker.ts @@ -6,8 +6,9 @@ // the stop, because that fact exists only in memory at that moment — the provider rewrites the // journal in its own words on reattach. // -// A marker has no expiry. It ends only by the user's own actions: a newer message in that chat, a -// successful resume, a dismissal, or closing the chat — each of which deletes it. +// A marker has no expiry. It ends when the chat's agent is started again other than by its own +// continuation, or by a successful resume, a dismissal, or closing the chat — each of which +// deletes it. import { z } from 'zod' import { AGENT_STATUS_STATES } from './agent-status-types' @@ -18,6 +19,7 @@ import { AGENT_SESSION_RESTART_ACTIVITY_MAX_TASKS } from './agent-session-restart-activity' import type { AgentSessionRestartActivity } from './agent-session-restart-activity' +import type { AgentJournalCursor } from './agent-session-journal-types' /** Why the app went away. Recorded because an update install is a restart the user did not choose, * and the surface that offers the resume says so. */ @@ -48,8 +50,9 @@ export type AgentSessionResumeMarker = { /** The work in flight when teardown observed it — a running turn, or a send that had not yet * become one. */ work: AgentSessionResumeWork - /** The user message observed at teardown; a newer one supersedes this offer before its turn opens. */ - latestUserItemId: string | null + /** The user message observed at teardown. Nothing reads it; still written for one release so the + * previous build, whose parser requires it, can read this marker after a downgrade. */ + latestUserItemId?: string | null /** Execution host's clock at teardown. */ recordedAt: number trigger: AgentSessionResumeTrigger @@ -65,6 +68,12 @@ export type AgentSessionResumeMarker = { providerHandleRoot: string /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string + /** + * Where the chat's journal stood when the offer was taken. A message accepted after it, or a + * journal on another epoch, means the chat moved on. Absent on markers from builds that did not + * record it. + */ + journalCursor?: AgentJournalCursor /** * What the session was doing, captured at the same stop-time snapshot that decided the offer. * The dialog row, the status bar and the wire candidate read ONLY this; nothing re-reads the @@ -114,11 +123,15 @@ const agentSessionRestartActivitySchema = z.object({ const agentSessionResumeMarkerSchema = z.object({ sessionId: markerField, work: agentSessionResumeWorkSchema, - latestUserItemId: markerField.nullable(), + latestUserItemId: markerField.nullable().optional(), recordedAt: z.number().int().nonnegative(), trigger: z.enum(AGENT_SESSION_RESUME_TRIGGERS), providerHandleRoot: markerField, teardownId: markerField, + journalCursor: z + .object({ epoch: markerField, sequence: z.number().int().nonnegative() }) + .optional() + .catch(undefined), activity: agentSessionRestartActivitySchema.optional().catch(undefined) }) @@ -129,6 +142,10 @@ export function parseAgentSessionResumeMarker(value: unknown): AgentSessionResum if (!parsed.success) { return null } - const { activity, ...marker } = parsed.data - return activity === undefined ? marker : { ...marker, activity } + const { activity, journalCursor, ...marker } = parsed.data + return { + ...marker, + ...(journalCursor === undefined ? {} : { journalCursor }), + ...(activity === undefined ? {} : { activity }) + } } diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index ec2e2452fa5..4cbc240c764 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -6,6 +6,7 @@ import type { TuiAgent } from './tui-agent' export const RESUMABLE_TUI_AGENTS = [ 'claude', 'codex', + 'qoder', 'gemini', 'antigravity', 'opencode', @@ -20,7 +21,8 @@ export const RESUMABLE_TUI_AGENTS = [ 'copilot', 'kimi', 'muse', - 'zcode' + 'zcode', + 'dsh' ] as const satisfies readonly TuiAgent[] export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number] @@ -193,6 +195,7 @@ export function extractAgentProviderSession( // Native-chat agents: also capture the hook's authoritative transcript_path, // since recent Claude Code names the transcript file with a UUID that differs // from the hook session_id (so the id-based glob no longer finds it). + case 'qoder': case 'claude': case 'codex': { const id = readSessionId(payload, ['session_id']) @@ -216,6 +219,12 @@ export function extractAgentProviderSession( const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null } + // Why: DSH's hook bridge always sends an empty `transcript_path` (its persistence seam + // exposes no artifact path), so the session id alone carries the resume target. + case 'dsh': { + const id = readSessionId(payload, ['session_id']) + return id ? { key: 'session_id', id } : null + } case 'antigravity': { const id = readSessionId(payload, ['conversationId']) return id ? { key: 'conversation_id', id } : null @@ -272,6 +281,8 @@ export function getAgentResumeArgv( return providerSession.key === 'session_id' ? ['claude', '--resume', id] : null case 'codex': return providerSession.key === 'session_id' ? ['codex', 'resume', id] : null + case 'qoder': + return providerSession.key === 'session_id' ? ['qodercli', '--resume', id] : null case 'gemini': return providerSession.key === 'session_id' ? ['gemini', '--resume', id] : null case 'antigravity': @@ -318,5 +329,9 @@ export function getAgentResumeArgv( return providerSession.key === 'session_id' ? ['muse', 'resume', id] : null case 'zcode': return providerSession.key === 'session_id' ? ['zcode', '--resume', id] : null + // Why: `dsh-tui --resume ` re-enters the session the launcher recorded for this + // workspace. DSH keys sessions by workspace path, so callers must keep the cwd. + case 'dsh': + return providerSession.key === 'session_id' ? ['dsh-tui', '--resume', id] : null } } diff --git a/src/shared/agent-session-wire-refusals.test.ts b/src/shared/agent-session-wire-refusals.test.ts new file mode 100644 index 00000000000..af017a617e7 --- /dev/null +++ b/src/shared/agent-session-wire-refusals.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_REFUSAL_REASONS, + readAgentSessionRefusalDetails +} from './agent-session-refusal-details' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + agentSessionRefusalError, + agentSessionRefusalReference, + readAgentSessionRefusalReference, + refuse, + refuseUnclassified, + withAgentSessionRefusalFacts, + type AgentSessionWireRefusal +} from './agent-session-wire-refusals' +import type { AgentJournalResolution } from './agent-session-journal-types' + +const RESOLUTION: AgentJournalResolution = { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'phone-1', + resolvedAt: 1_789_000_000_000 +} + +/** The refusal with its new field removed: exactly what a released client reads. */ +function asReleasedClientSeesIt(refusal: AgentSessionWireRefusal): unknown { + const { details: _details, ...rest } = JSON.parse(JSON.stringify(refusal)) + return rest +} + +describe('a refusal names only a reason its code lists', () => { + it('rejects a reason another code lists, at compile time', () => { + // @ts-expect-error `promptGone` is an operation_invalid reason, not a conflict one. + refuse('agent_session_conflict', { reason: 'promptGone' }, 'm') + // @ts-expect-error the thrown form is held to the same list. + agentSessionRefusalError('agent_session_identity_required', { reason: 'claimConflicted' }) + // @ts-expect-error a fact another code carries is not this code's. + refuse('agent_session_conflict', { reason: 'ownerAlive', currentFence: 3 }, 'm') + // @ts-expect-error the unclassified path cannot carry a reason. + refuseUnclassified('agent_session_conflict', 'm', { reason: 'claimConflicted' }) + expect(refuse('agent_session_conflict', { reason: 'ownerAlive' }, 'm').details).toEqual({ + reason: 'ownerAlive' + }) + }) + + it('has no catch-all reason under any code', () => { + for (const code of AGENT_SESSION_WIRE_REFUSAL_CODES) { + for (const reason of AGENT_SESSION_REFUSAL_REASONS[code]) { + expect([code, reason]).not.toEqual([code, expect.stringMatching(/^(other|unknown)$/i)]) + expect(reason).not.toMatch(/fault|generic|misc/i) + } + } + }) +}) + +describe('the loose fields released clients read', () => { + it('are copied from details by refuse, one field at a time', () => { + expect( + refuse('agent_session_checkpoint_stale', { reason: 'fenceStale', currentFence: 4 }, 'm') + ).toMatchObject({ currentFence: 4 }) + expect( + refuse( + 'agent_session_item_revision_stale', + { reason: 'promptMoved', currentRevision: 2, resolution: RESOLUTION }, + 'm' + ) + ).toMatchObject({ currentRevision: 2, resolution: RESOLUTION }) + expect( + refuse( + 'agent_session_operation_invalid', + { reason: 'rewindRefused', rewindReason: 'busy' }, + 'm' + ) + ).toMatchObject({ rewindReason: 'busy' }) + expect( + refuse( + 'agent_session_ownership_unknown', + { reason: 'ownerUnproven', ownerVerdict: 'exited' }, + 'm' + ) + ).toMatchObject({ ownerVerdict: 'exited' }) + expect( + refuseUnclassified('agent_session_checkpoint_stale', 'm', { currentFence: 9 }) + ).toMatchObject({ currentFence: 9, details: { currentFence: 9 } }) + }) + + it('gain a verdict learned after the refusal, in details and at the top level', () => { + const stamped = withAgentSessionRefusalFacts( + refuse('agent_session_operation_invalid', { reason: 'providerStartFailed' }, 'm'), + { ownerVerdict: 'exited' } + ) + expect(stamped).toEqual({ + code: 'agent_session_operation_invalid', + message: 'm', + details: { reason: 'providerStartFailed', ownerVerdict: 'exited' }, + ownerVerdict: 'exited' + }) + // A refusal an older row replayed, with no reason, still reaches released clients with it. + expect( + withAgentSessionRefusalFacts(refuseUnclassified('agent_session_conflict', 'm'), { + ownerVerdict: 'unverifiable' + }) + ).toEqual({ + code: 'agent_session_conflict', + message: 'm', + details: { ownerVerdict: 'unverifiable' }, + ownerVerdict: 'unverifiable' + }) + }) + + // Each expected value is the literal the host built for the same situation before details. + it.each([ + [ + 'a moved prompt', + refuse( + 'agent_session_item_revision_stale', + { reason: 'promptMoved', currentRevision: 2, resolution: RESOLUTION }, + 'Item i has moved on.' + ), + { + code: 'agent_session_item_revision_stale', + message: 'Item i has moved on.', + currentRevision: 2, + resolution: RESOLUTION + } + ], + [ + 'a resolved prompt', + refuse( + 'agent_session_already_resolved', + { reason: 'promptAlreadyResolved', currentRevision: 2, resolution: RESOLUTION }, + 'Item i was already resolved.' + ), + { + code: 'agent_session_already_resolved', + message: 'Item i was already resolved.', + currentRevision: 2, + resolution: RESOLUTION + } + ], + [ + 'a stale fence', + refuse('agent_session_checkpoint_stale', { reason: 'fenceStale', currentFence: 4 }, 'm'), + { code: 'agent_session_checkpoint_stale', message: 'm', currentFence: 4 } + ], + [ + 'a refused rewind', + refuse( + 'agent_session_operation_invalid', + { reason: 'rewindRefused', rewindReason: 'busy' }, + 'agent_session_rewind:busy' + ), + { + code: 'agent_session_operation_invalid', + message: 'agent_session_rewind:busy', + rewindReason: 'busy' + } + ], + [ + 'a failed create', + withAgentSessionRefusalFacts( + refuse('agent_session_operation_invalid', { reason: 'providerStartFailed' }, 'exited 1'), + { ownerVerdict: 'exited' } + ), + { code: 'agent_session_operation_invalid', message: 'exited 1', ownerVerdict: 'exited' } + ], + [ + 'a refusal with no facts', + refuse('agent_session_conflict', { reason: 'claimConflicted' }, 'm'), + { code: 'agent_session_conflict', message: 'm' } + ] + ] as const)('read by a released client exactly as before for %s', (_label, refusal, before) => { + expect(asReleasedClientSeesIt(refusal)).toEqual(before) + }) +}) + +describe('a stored refusal read back', () => { + it('keeps what the code lists and drops the rest', () => { + expect( + readAgentSessionRefusalDetails('agent_session_checkpoint_stale', { + reason: 'fenceStale', + currentFence: 3, + currentRevision: 8, + cause: 'fenceStale' + }) + ).toEqual({ reason: 'fenceStale', currentFence: 3 }) + // A reason a newer host added reads as none, but the facts beside it still count. + expect( + readAgentSessionRefusalDetails('agent_session_checkpoint_stale', { + reason: 'futureReason', + currentFence: 3 + }) + ).toEqual({ currentFence: 3 }) + expect(readAgentSessionRefusalDetails('agent_session_conflict', { reason: 'x' })).toBe( + undefined + ) + }) + + it('round-trips through its reference, and reads an older shape as naming nothing', () => { + const refusal = refuse( + 'agent_session_ownership_unknown', + { reason: 'noLiveOwner', ownerVerdict: 'live' }, + 'm' + ) + const reference = agentSessionRefusalReference(refusal) + expect(reference).toEqual({ + code: 'agent_session_ownership_unknown', + details: { reason: 'noLiveOwner', ownerVerdict: 'live' } + }) + expect(readAgentSessionRefusalReference(JSON.parse(JSON.stringify(reference)))).toEqual( + reference + ) + expect( + readAgentSessionRefusalReference({ code: 'agent_session_conflict', cause: 'claimConflicted' }) + ).toEqual({ code: 'agent_session_conflict' }) + expect(readAgentSessionRefusalReference({ code: 'agent_session_future' })).toBeUndefined() + }) +}) diff --git a/src/shared/agent-session-wire-refusals.ts b/src/shared/agent-session-wire-refusals.ts index 12cd63d4490..8fd1602377b 100644 --- a/src/shared/agent-session-wire-refusals.ts +++ b/src/shared/agent-session-wire-refusals.ts @@ -1,8 +1,24 @@ // How the host declines an `agentSession.*` mutation: the closed code list, its // narrowing guard, and the refusal body a client reads. -import type { AgentJournalResolution } from './agent-session-journal-types' -import type { AgentSessionRewindReason } from './agent-session-rewind' +import { + agentSessionLegacyRefusalFields, + readAgentSessionRefusalDetails, + type AgentSessionAnyRefusalDetails, + type AgentSessionLegacyRefusalFields, + type AgentSessionRefusalDetails, + type AgentSessionRefusalDetailsByCode, + type AgentSessionRefusalFacts +} from './agent-session-refusal-details' + +export type { + AgentSessionAnyRefusalDetails, + AgentSessionAnyRefusalReason, + AgentSessionLegacyRefusalFields, + AgentSessionRefusalDetails, + AgentSessionRefusalDetailsByCode, + AgentSessionRefusalReason +} from './agent-session-refusal-details' export const AGENT_SESSION_WIRE_REFUSAL_CODES = [ 'structured_agent_session_unsupported', @@ -38,17 +54,140 @@ export function isAgentSessionWireRefusalCode( /** What the host last proved about a session's provider process; see the SSH execution boundary. */ export type AgentSessionOwnerVerdict = 'live' | 'unverifiable' | 'exited' -export type AgentSessionWireRefusal = { - rewindReason?: AgentSessionRewindReason - code: AgentSessionWireRefusalCode +/** One variant per code, so a refusal's details can only be that code's. */ +export type AgentSessionRefusalOf = { + [K in C]: { + code: K + /** For logs and released clients, which print it; newer clients choose words from details. */ + message: string + /** Absent from older hosts. */ + details?: AgentSessionRefusalDetails + } & AgentSessionLegacyRefusalFields +}[C] + +export type AgentSessionWireRefusal = AgentSessionRefusalOf + +/** A refusal named without its prose: what a durable record or an error's data keeps. */ +export type AgentSessionRefusalReference = { + [K in AgentSessionWireRefusalCode]: { code: K; details?: AgentSessionRefusalDetails } +}[AgentSessionWireRefusalCode] + +function buildRefusal( + code: AgentSessionWireRefusalCode, + details: AgentSessionAnyRefusalDetails | undefined, message: string - /** On a stale fence, so the client can retry without another round trip. */ - currentFence?: number - /** On a lost compare-and-set: the winning answer and who gave it. */ - resolution?: AgentJournalResolution - /** On a lost compare-and-set: the revision the host actually holds. */ - currentRevision?: number - /** On a durably failed create: `exited` proves nothing runs for the session, so a new - * operation cannot collide with this one. Absent (older hosts) reads as unverifiable. */ - ownerVerdict?: AgentSessionOwnerVerdict +): AgentSessionWireRefusal { + const kept = details && Object.keys(details).length > 0 ? details : undefined + const refusal = { + code, + message, + ...(kept ? { details: kept } : {}), + ...agentSessionLegacyRefusalFields(kept) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every caller passes details typed for `code` (refuse and refuseUnclassified by their signatures, the reference by its own union). + return refusal as AgentSessionWireRefusal +} + +/** A refusal built at its emitter. The reason must be one `code` lists; the legacy top-level fields + * released clients read are copied from `details` here and nowhere else. */ +export function refuse( + code: C, + details: NoInfer, + message: string +): AgentSessionWireRefusal { + return buildRefusal(code, details, message) +} + +/** + * A refusal whose situation this site cannot name: the code, and whatever facts it does know, with + * no reason, exactly as an older host sends it. The explicit path, so no emitter reaches for a + * catch-all reason instead. + */ +export function refuseUnclassified( + code: C, + message: string, + facts?: NoInfer> +): AgentSessionWireRefusal { + return buildRefusal(code, facts, message) +} + +/** A refusal rebuilt from what a record or a decision kept of it. */ +export function agentSessionRefusalFromReference( + reference: AgentSessionRefusalReference, + message: string +): AgentSessionWireRefusal { + return buildRefusal(reference.code, reference.details, message) +} + +/** The same refusal with more facts: a failed create's verdict, learned after the refusal was. */ +export function withAgentSessionRefusalFacts( + refusal: AgentSessionWireRefusal, + facts: { ownerVerdict: AgentSessionOwnerVerdict } +): AgentSessionWireRefusal { + return buildRefusal(refusal.code, { ...refusal.details, ...facts }, refusal.message) +} + +/** The refusal reduced to what may be stored or put in an error's data. */ +export function agentSessionRefusalReference( + refusal: AgentSessionWireRefusal +): AgentSessionRefusalReference { + return buildReference(refusal.code, refusal.details) +} + +/** A reference read back from a record another build may have written; details are checked + * against the code, and anything else they held (an older build's `cause`) is dropped. */ +export function readAgentSessionRefusalReference( + value: unknown +): AgentSessionRefusalReference | undefined { + if ( + typeof value !== 'object' || + value === null || + !('code' in value) || + !isAgentSessionWireRefusalCode(value.code) + ) { + return undefined + } + return buildReference( + value.code, + 'details' in value ? readAgentSessionRefusalDetails(value.code, value.details) : undefined + ) +} + +function buildReference( + code: AgentSessionWireRefusalCode, + details: AgentSessionAnyRefusalDetails | undefined +): AgentSessionRefusalReference { + const reference = { code, ...(details ? { details } : {}) } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `details` is the refusal's own, or was read against `code`. + return reference as AgentSessionRefusalReference +} + +/** + * A refusal raised rather than returned. Its message is the bare code, exactly as the + * `Error(code)` it replaces: the RPC passthrough, the restart-resume ledger and released clients + * all read a thrown refusal's message as its code. The details ride on `refusal` and reach the + * client only in the RPC error's data. Deliberately no `code` property, so no `'code' in error` + * passthrough meant for another subsystem can claim it. + */ +export class AgentSessionRefusalError extends Error { + readonly refusal: AgentSessionWireRefusal + + constructor(refusal: AgentSessionWireRefusal, options?: { cause?: unknown }) { + super(refusal.code, options) + this.name = 'AgentSessionRefusalError' + this.refusal = refusal + } +} + +/** A store or host refusal thrown under its situation; the error's message stays the code. */ +export function agentSessionRefusalError( + code: C, + details: NoInfer, + message: string = code +): AgentSessionRefusalError { + return new AgentSessionRefusalError(refuse(code, details, message)) +} + +export function isAgentSessionRefusalError(error: unknown): error is AgentSessionRefusalError { + return error instanceof AgentSessionRefusalError } diff --git a/src/shared/agent-session-write-failure.test.ts b/src/shared/agent-session-write-failure.test.ts new file mode 100644 index 00000000000..0b614b3a265 --- /dev/null +++ b/src/shared/agent-session-write-failure.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionRefusalFailure, + agentSessionThrownFailure, + parseAgentSessionWriteFailure, + readAgentSessionErrorRefusal +} from './agent-session-write-failure' + +const HOST_TEXT = 'Expected runtime fence 1; the session is at 3.' + +function saved(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)) +} + +describe('parseAgentSessionWriteFailure', () => { + // The shape saved before refusals carried a reason; it loads as it was and reads as its code. + it('reads back an entry saved with the code alone, unchanged', () => { + expect( + parseAgentSessionWriteFailure(saved({ kind: 'refused', code: 'agent_session_conflict' })) + ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) + expect(parseAgentSessionWriteFailure({ kind: 'failed' })).toEqual({ kind: 'failed' }) + expect( + parseAgentSessionWriteFailure({ + kind: 'refused', + code: 'agent_session_conflict', + message: HOST_TEXT + }) + ).toEqual({ kind: 'refused', code: 'agent_session_conflict' }) + }) + + it.each([ + [ + 'a reason', + { kind: 'refused', code: 'agent_session_conflict', details: { reason: 'chatStarting' } } + ], + [ + "a rewind's reason", + { + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'rewindRefused', rewindReason: 'busy' } + } + ], + [ + "the owner's verdict", + { + kind: 'refused', + code: 'agent_session_ownership_unknown', + details: { reason: 'ownerUnproven', ownerVerdict: 'exited' } + } + ] + ])('keeps %s', (_label, value) => { + expect(parseAgentSessionWriteFailure(saved(value))).toEqual(value) + }) + + // A newer build's reason or verdict is dropped, never guessed; the code's words stand. + it.each([ + [{ reason: 'fromTheFuture' }, undefined], + [{ reason: 'fromTheFuture', ownerVerdict: 'exited' }, { ownerVerdict: 'exited' }], + [{ reason: 'ownerUnproven', ownerVerdict: 'gone' }, { reason: 'ownerUnproven' }], + [{ reason: 'chatStarting' }, undefined] + ])('drops what this build cannot place: %j', (details, kept) => { + expect( + parseAgentSessionWriteFailure({ + kind: 'refused', + code: 'agent_session_ownership_unknown', + details + }) + ).toEqual({ + kind: 'refused', + code: 'agent_session_ownership_unknown', + ...(kept ? { details: kept } : {}) + }) + }) + + it.each([ + null, + 'The agent was restarting.', + { kind: 'refused' }, + { kind: 'refused', code: 'agent_session_from_the_future' }, + { kind: 'something-else' } + ])('drops %j instead of guessing', (value) => { + expect(parseAgentSessionWriteFailure(value)).toBeUndefined() + }) +}) + +describe('agentSessionRefusalFailure', () => { + // What moves with the owner, the question's answer and the host's words are never kept. + it('keeps only the facts that stay true after a reload', () => { + expect( + agentSessionRefusalFailure({ + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale', currentFence: 3, ownerVerdict: 'exited' } + }) + ).toEqual({ + kind: 'refused', + code: 'agent_session_checkpoint_stale', + details: { reason: 'fenceStale' } + }) + // A prompt's revision and winning answer, and a provider's diagnostic, as a host may send them. + const answered = JSON.parse( + JSON.stringify({ + code: 'agent_session_already_resolved', + details: { + reason: 'promptAlreadyResolved', + currentRevision: 2, + resolution: { state: 'answered', selectedOptionId: 'yes', resolvedBy: 'phone' }, + detail: { text: 'stderr', audience: 'log' } + } + }) + ) + expect(agentSessionRefusalFailure(answered)).toEqual({ + kind: 'refused', + code: 'agent_session_already_resolved', + details: { reason: 'promptAlreadyResolved' } + }) + }) + + it('keeps a code from a newer host without reading its details', () => { + const refusal = JSON.parse('{"code":"agent_session_from_the_future","details":{"reason":"x"}}') + expect(agentSessionRefusalFailure(refusal)).toEqual({ + kind: 'refused', + code: 'agent_session_from_the_future' + }) + }) +}) + +describe('a refusal a failed request carried in its error', () => { + const refusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt', stray: 'dropped' } + } + const payload = { code: 'runtime_error', message: 'agent_session_journal_unreadable' } + + it('is read from a thrown RPC error and from a stream payload alike', () => { + const expected = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + expect( + readAgentSessionErrorRefusal({ response: { error: { ...payload, data: { refusal } } } }) + ).toEqual(expected) + expect(readAgentSessionErrorRefusal(saved({ ...payload, data: { refusal } }))).toEqual(expected) + }) + + it.each([ + ['an older host', payload], + ['a thrown error without a response', new Error('agent_session_journal_unreadable')], + ['a code this build does not know', { ...payload, data: { refusal: { code: 'from_later' } } }], + ['nothing', undefined] + ])('is absent from %s', (_label, error) => { + expect(readAgentSessionErrorRefusal(error)).toBeUndefined() + }) + + it("words the refusal when there is one, else what the request's error code proves", () => { + expect(agentSessionThrownFailure({ ...payload, data: { refusal } }, 'runtime_error')).toEqual({ + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + }) + expect(agentSessionThrownFailure(payload, 'runtime_error')).toEqual({ kind: 'unconfirmed' }) + expect(agentSessionThrownFailure(payload, 'method_not_found')).toEqual({ + kind: 'refused', + code: 'structured_agent_session_unsupported' + }) + }) +}) diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts new file mode 100644 index 00000000000..81809133871 --- /dev/null +++ b/src/shared/agent-session-write-failure.ts @@ -0,0 +1,183 @@ +// A chat write that did not happen, as a fact: what the person was doing, and what the host said. +// Saved with a queued message, so it holds only what stays true after a reload; the words are +// chosen from it when it is shown (`agent-session-refusal-notice.ts`). + +import { + readAgentSessionRefusalDetails, + type AgentSessionRefusalReason +} from './agent-session-refusal-details' +import type { AgentSessionRewindReason } from './agent-session-rewind' +import { + isAgentSessionWireRefusalCode, + readAgentSessionRefusalReference, + type AgentSessionOwnerVerdict, + type AgentSessionRefusalReference, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from './agent-session-wire-refusals' + +/** What the person was doing, which decides what the notice says did not happen. `send` keeps + * the message behind a Retry control; `composer-send` puts it back in the composer, as the phone + * does. `read-history` is opening the chat to show its history, which writes nothing. */ +export type AgentSessionWriteKind = + | 'read-history' + | 'send' + | 'composer-send' + | 'stop' + | 'stop-task' + | 'stop-tasks' + | 'answer' + | 'option' + | 'command' + | 'goal' + +/** The kind of write an `agentSession.*` call stands for. */ +export function agentSessionWriteKindForMethod( + fingerprintMethod: string, + fields: Record +): AgentSessionWriteKind { + if (fingerprintMethod === 'agentSession.send') { + return 'send' + } + if (fingerprintMethod === 'agentSession.cancel') { + // A background-task stop never asked the agent to stop. + if (fields.scope !== 'background-tasks') { + return 'stop' + } + return typeof fields.taskId === 'string' ? 'stop-task' : 'stop-tasks' + } + if (fingerprintMethod.startsWith('agentSession.respondTo')) { + return 'answer' + } + if (fingerprintMethod === 'agentSession.setOption') { + return 'option' + } + if (fingerprintMethod === 'agentSession.threadGoal') { + return 'goal' + } + return 'command' +} + +/** What a saved refusal keeps beside its reason: facts about the refused operation that stay true + * after a reload. Never the fence, revision or resolution, which move, nor any provider text. */ +type DurableRefusalFacts = { + agent_session_operation_invalid: { rewindReason?: AgentSessionRewindReason } + agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason } + /** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */ + agent_session_ownership_unknown: { ownerVerdict?: AgentSessionOwnerVerdict } +} + +const DURABLE_FACT_KEYS: Partial> = { + agent_session_operation_invalid: ['rewindReason'], + agent_session_operation_unknown: ['rewindReason'], + agent_session_ownership_unknown: ['ownerVerdict'] +} satisfies { [C in keyof DurableRefusalFacts]: readonly (keyof DurableRefusalFacts[C])[] } + +export type AgentSessionWriteRefusalDetails = { + reason?: AgentSessionRefusalReason +} & (C extends keyof DurableRefusalFacts ? DurableRefusalFacts[C] : Record) + +/** A refused write: its code, and what the host said about it that outlives the reply. */ +export type AgentSessionWriteRefusal = { + [C in AgentSessionWireRefusalCode]: { + kind: 'refused' + code: C + /** Absent from older hosts and from entries saved before refusals carried a reason. */ + details?: AgentSessionWriteRefusalDetails + } +}[AgentSessionWireRefusalCode] + +/** Why a write did not happen, or that nothing proves it did not. */ +export type AgentSessionWriteFailure = + | AgentSessionWriteRefusal + /** The request failed without a refusal before the host ran it, so nothing is known about why. */ + | { kind: 'failed' } + /** The request failed where the host may already have run it (a timeout, a lost connection, an + * error inside the method). */ + | { kind: 'unconfirmed' } + +/** The refusal as a write keeps it. Details are read against the code, so a reason or verdict + * another build added is dropped and the code's own words stand. */ +function agentSessionWriteRefusal( + code: AgentSessionWireRefusalCode, + details: unknown +): AgentSessionWriteRefusal { + const durable: readonly string[] = ['reason', ...(DURABLE_FACT_KEYS[code] ?? [])] + const kept = Object.fromEntries( + Object.entries(readAgentSessionRefusalDetails(code, details) ?? {}).filter(([key]) => + durable.includes(key) + ) + ) + const refusal = { + kind: 'refused', + code, + ...(Object.keys(kept).length > 0 ? { details: kept } : {}) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `kept` holds only a reason and facts read against `code` and allowed for it above. + return refusal as AgentSessionWriteRefusal +} + +export function agentSessionRefusalFailure( + refusal: Pick +): AgentSessionWriteRefusal { + // A newer host can send a code this client has never heard of; its details mean nothing here. + return isAgentSessionWireRefusalCode(refusal.code) + ? agentSessionWriteRefusal(refusal.code, refusal.details) + : { kind: 'refused', code: refusal.code } +} + +/** A request that threw, from the RPC error code the host answered with (undefined when none came + * back). Only a host that turned it away before running the method proves the write did not + * happen. */ +export function agentSessionRpcErrorFailure(code: string | undefined): AgentSessionWriteFailure { + if (code === 'method_not_found' || code === 'method_not_supported') { + return { kind: 'refused', code: 'structured_agent_session_unsupported' } + } + return code === 'invalid_argument' || code === 'unauthorized' + ? { kind: 'refused', code: 'agent_session_operation_invalid' } + : { kind: 'unconfirmed' } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** The refusal a failed request's error carries in its data. Takes both shapes a client meets: an + * RPC call's thrown error, whose payload is on `response.error`, and the payload a stream hands + * its error callback. Undefined from an older host, or for a failure that was not a refusal. */ +export function readAgentSessionErrorRefusal( + error: unknown +): AgentSessionRefusalReference | undefined { + const payload = + isRecord(error) && isRecord(error.response) && isRecord(error.response.error) + ? error.response.error + : error + const data = isRecord(payload) ? payload.data : undefined + return isRecord(data) ? readAgentSessionRefusalReference(data.refusal) : undefined +} + +/** What to say about a request that threw: the host's refusal when its error carried one, else + * what the RPC error code proves. Words only: whether the write may have happened stays the + * caller's own classification. */ +export function agentSessionThrownFailure( + error: unknown, + rpcCode: string | undefined +): AgentSessionWriteFailure { + const refusal = readAgentSessionErrorRefusal(error) + return refusal ? agentSessionRefusalFailure(refusal) : agentSessionRpcErrorFailure(rpcCode) +} + +/** A saved failure, or undefined when it is not one this build wrote. */ +export function parseAgentSessionWriteFailure( + value: unknown +): AgentSessionWriteFailure | undefined { + if (typeof value !== 'object' || value === null || !('kind' in value)) { + return undefined + } + if (value.kind === 'failed') { + return { kind: 'failed' } + } + return value.kind === 'refused' && 'code' in value && isAgentSessionWireRefusalCode(value.code) + ? agentSessionWriteRefusal(value.code, 'details' in value ? value.details : undefined) + : undefined +} diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts new file mode 100644 index 00000000000..d0c36e27a40 --- /dev/null +++ b/src/shared/agent-session-write-notice-copy.ts @@ -0,0 +1,64 @@ +// The sentences a chat notice is made of, each whole so desktop can translate it on its own. + +import { + QUIT_TERMINAL_AGENT, + START_NEW_CHAT, + TERMINAL_AGENT_HOLDS_CHAT +} from './agent-session-failure-words' + +/** Every sentence a notice is made of. Desktop translates each whole sentence with this as its + * fallback; mobile shows it as is. */ +export const AGENT_SESSION_WRITE_NOTICE_COPY = { + notDoneReadHistory: "This chat's history couldn't be loaded.", + notDoneSend: 'Your message was not sent.', + tryAgainComposerSend: 'Send it again.', + notDoneStop: "The agent wasn't stopped.", + notDoneStopTask: "The background task wasn't stopped.", + notDoneStopTasks: "The background tasks weren't stopped.", + notDoneAnswer: 'Your answer was not sent.', + notDoneOption: "The setting wasn't changed.", + notDoneCommand: "The command didn't run.", + notDoneGoal: "The goal wasn't changed.", + restartFailed: "The agent couldn't restart.", + capacity: 'Orca has received too many requests in the last day.', + outcomeUnknown: "Orca couldn't confirm what happened. Check the chat.", + questionChanged: 'This question was already answered or has changed.', + historyUnreadable: "Orca couldn't read this chat's saved history.", + historyUnusable: 'Unable to load this chat.', + historyUnavailable: "Orca couldn't open this chat's history right now.", + unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", + unreachable: "Orca couldn't reach the agent.", + recordFailed: "Orca couldn't record it in this chat's history.", + conversationCleared: 'This conversation has been cleared.', + openCurrentConversation: 'Open the current conversation to continue.', + clearUnfinished: "The last /clear didn't finish.", + commandRunning: 'A /compact or /clear is still running.', + waitForCommand: 'Wait for the /compact or /clear to finish.', + agentStarting: 'The agent is still starting.', + waitForStart: 'Wait for the agent to finish starting.', + turnActive: 'The agent is still responding.', + waitForTurn: 'Wait for the agent to finish responding, or stop it.', + promptPending: 'The agent is waiting for an answer to a question or approval.', + answerFirst: 'Answer the question or approval first.', + backgroundTasksRunning: 'Background tasks are still running.', + waitForBackgroundTasks: 'Wait for the background tasks to finish.', + messagesUnsettled: "A message you sent earlier hasn't been confirmed yet.", + settleEarlierMessage: 'Wait for your earlier message to go through, or retry it.', + optionRejected: "The agent didn't accept this setting.", + goalsUnsupported: "This agent doesn't support goals.", + agentRefused: 'The agent turned this down.', + ownerUnproven: "Orca hasn't confirmed that this chat's previous agent stopped.", + reopenChat: 'Reopen the chat to check again.', + terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, + quitTerminalAgent: QUIT_TERMINAL_AGENT, + hostReconciling: 'Orca is still checking on this chat after restarting.', + waitMoment: 'Wait a moment.', + recordUnreadable: "Orca couldn't read this chat's saved state.", + chatNotFound: 'The Orca running this chat has no record of it.', + startNewChat: START_NEW_CHAT, + tryAgain: 'Try again.' +} as const + +export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY +/** A notice as whole sentences, each translated on its own; `text` is a provider's own words. */ +export type AgentSessionWriteNoticePart = AgentSessionWriteNoticeSentence | { text: string } diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index 3d0bdbc0feb..a3bc238a7e1 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -1,3 +1,6 @@ +import { isDshTerminalTitle } from './dsh-terminal-title' +export { DSH_WHALE, isDshTerminalTitle } from './dsh-terminal-title' +import { isQoderTerminalTitle } from './qoder-terminal-title' import { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, @@ -56,6 +59,13 @@ export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g export const QUARTER_CIRCLE_SPINNER_RE = /[\u25d0-\u25d3]/g function computeIsGeminiTerminalTitle(title: string): boolean { + // Why first: see isDshTerminalTitle — the two agents share the `✦` glyph. + if (isQoderTerminalTitle(title)) { + return false + } + if (isDshTerminalTitle(title)) { + return false + } // Why: Gemini OSC glyphs are stronger evidence than any cwd/session text. if ( title.includes(GEMINI_PERMISSION) || diff --git a/src/shared/agent-title-decoration.test.ts b/src/shared/agent-title-decoration.test.ts index a87426c83cf..5a5666696e0 100644 --- a/src/shared/agent-title-decoration.test.ts +++ b/src/shared/agent-title-decoration.test.ts @@ -5,6 +5,11 @@ import { } from './agent-title-decoration' describe('stripLeadingAgentTitleDecoration', () => { + it('strips Qoder permission decoration without stripping triangles inside a title', () => { + expect(stripLeadingAgentTitleDecoration('▲ Qoder CLI')).toBe('Qoder CLI') + expect(stripLeadingAgentTitleDecoration('deploy ▲ complete')).toBe('deploy ▲ complete') + }) + it("strips Claude's ✳ idle glyph", () => { expect(stripLeadingAgentTitleDecoration('✳ Claude Code')).toBe('Claude Code') }) diff --git a/src/shared/agent-title-decoration.ts b/src/shared/agent-title-decoration.ts index 47a6db679e7..3ae4d0075e6 100644 --- a/src/shared/agent-title-decoration.ts +++ b/src/shared/agent-title-decoration.ts @@ -1,11 +1,11 @@ // Leading status decorations that coding agents prepend to their OSC title — -// Claude's '✳', Gemini's glyphs (✦ ⏲ ◇ ✋), braille and quarter-circle spinners, +// Claude's '✳', Gemini's glyphs (✦ ⏲ ◇ ✋), Qoder’s ▲, braille and quarter-circle spinners, // and Claude's '. '/'* ' working/idle prefixes. Once the tab bar shows the // provider icon, this leading glyph reads as a redundant second icon, so strip // it from the displayed title. Scoped to titles we already know belong to an agent. const LEADING_AGENT_TITLE_DECORATION_RE = // eslint-disable-next-line no-control-regex -- intentional unicode status-glyph ranges - /^(?:[✳✦⏲◇✋⠀-⣿◐-◓]+|[.*]\s)\s*/ + /^(?:[✳✦⏲◇✋▲⠀-⣿◐-◓]+|[.*]\s)\s*/ export function stripLeadingAgentTitleDecorationOrEmpty(title: string): string { return title.replace(LEADING_AGENT_TITLE_DECORATION_RE, '').trimStart() diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 949768e03b5..69d1531fb9e 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -1,3 +1,4 @@ +import { isQoderTerminalTitle } from './qoder-terminal-title' import { getPiStateTitleBrand } from './pi-state-title-marker' import { AGY_AGENT_NAME_RE, @@ -72,6 +73,9 @@ function computeAgentLabel(title: string): string | null { if (piStateBrand) { return piStateBrand } + if (isQoderTerminalTitle(title)) { + return 'Qoder CLI' + } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index a74ae15d6bf..7cd7a2c292c 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -1,3 +1,4 @@ +import { qoderTitleStatus } from './qoder-terminal-title' import { AGY_AGENT_NAME_RE, BRAILLE_SPINNER_RE, @@ -19,6 +20,7 @@ import { containsQuarterCircleSpinner, containsLegacyAgentName, isClaudeManagementTitle, + isDshTerminalTitle, isGeminiTerminalTitle, isPiAgentTitle, isPiTerminalTitle @@ -128,6 +130,10 @@ export function createAgentStatusTracker( * Normalize high-churn agent titles into stable display labels before storage. */ export function normalizeTerminalTitle(title: string): string { + const qoderStatus = qoderTitleStatus(title) + if (qoderStatus) { + return `${qoderStatus === 'working' ? '✦' : qoderStatus === 'permission' ? '▲' : '◇'} Qoder CLI` + } if (!title) { return title } @@ -180,6 +186,10 @@ function canonicalizeBrailleSpinnerFrame(title: string): string { } function computeAgentStatusFromTitle(title: string): AgentStatus | null { + const qoderStatus = qoderTitleStatus(title) + if (qoderStatus) { + return qoderStatus + } if (!title || isClaudeManagementTitle(title)) { return null } @@ -198,14 +208,20 @@ function computeAgentStatusFromTitle(title: string): AgentStatus | null { return piStateStatus } - if (title.includes(GEMINI_PERMISSION)) { - return 'permission' - } - if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { - return 'working' - } - if (title.includes(GEMINI_IDLE)) { - return 'idle' + // Why the guard: DSH-TUI prefixes its title with `✦` while it is at REST, and that is + // Gemini's WORKING glyph. Reading it here made a finished DSH pane report working + // forever. DSH's own spinner prefixes are braille, which the spinner check below + // already covers, and its hooks are the authority either way. + if (!isDshTerminalTitle(title)) { + if (title.includes(GEMINI_PERMISSION)) { + return 'permission' + } + if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { + return 'working' + } + if (title.includes(GEMINI_IDLE)) { + return 'idle' + } } // Why: resolve synthetic Pi/OMP permission/idle labels before the broader diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index 24febeaafa7..a4d70a76037 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -6,6 +6,8 @@ const WELL_KNOWN_LABELS: Record = { claude: 'Claude', openclaude: 'OpenClaude', codex: 'Codex', + qoder: 'Qoder CLI', + qodercli: 'Qoder CLI', gemini: 'Gemini', antigravity: 'Antigravity', amp: 'Amp', @@ -27,7 +29,8 @@ const WELL_KNOWN_LABELS: Record = { trae: 'Trae', kimi: 'Kimi', muse: 'Muse', - zcode: 'ZCode' + zcode: 'ZCode', + dsh: 'DeepSeek Harness' } export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index fbd0658def1..cfff419c95c 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -219,6 +219,7 @@ function buildAgentResumeInvocation( case 'cline': return `${baseCommand} --id ${sessionArg}` case 'claude': + case 'zcode': case 'cursor': case 'gemini': case 'grok': diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index b2635555bf6..28f93dcfbe1 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -15,6 +15,7 @@ export const AI_VAULT_AGENTS = [ 'copilot', 'opencode', 'opencode2', + 'zcode', 'grok', 'openclaw', 'devin', @@ -62,6 +63,7 @@ export const AI_VAULT_AGENT_LABELS = { copilot: 'GitHub Copilot', opencode: 'OpenCode', opencode2: 'OpenCode 2', + zcode: 'ZCode', grok: 'Grok', openclaw: 'OpenClaw', devin: 'Devin', diff --git a/src/shared/closed-terminal-tab-tombstones.test.ts b/src/shared/closed-terminal-tab-tombstones.test.ts index a2db1828ac3..b28c64d9dd9 100644 --- a/src/shared/closed-terminal-tab-tombstones.test.ts +++ b/src/shared/closed-terminal-tab-tombstones.test.ts @@ -2,37 +2,29 @@ import { describe, expect, it } from 'vitest' import { CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS, MAX_CLOSED_TERMINAL_TAB_TOMBSTONES, + closedTerminalTabTombstoneSchema, + hasClosedTerminalTabRecord, + isTerminalWorkspaceEmptiedOnPurpose, pruneClosedTerminalTabTombstones, recordClosedTerminalTabTombstone, - reconcileClosedTerminalTabTombstones, type ClosedTerminalTabTombstonesByTabId } from './closed-terminal-tab-tombstones' const NOW = 1_800_000_000_000 const WT = 'repo-1::/srv/app' -function reconcile( - tombstones: ClosedTerminalTabTombstonesByTabId, - args: { - acknowledgedWorktreeIds?: string[] - hostKnownTabIds?: string[] - hostRevision?: number - } -): ClosedTerminalTabTombstonesByTabId { - return reconcileClosedTerminalTabTombstones({ - tombstones, - acknowledgedWorktreeIds: new Set(args.acknowledgedWorktreeIds ?? [WT]), - hostKnownTabIds: new Set(args.hostKnownTabIds ?? []), - hostRevision: args.hostRevision, - now: NOW +describe('closed terminal tab records', () => { + it('records the closing worktree, reason and time', () => { + expect( + recordClosedTerminalTabTombstone({}, 'tab-1', { worktreeId: WT, reason: 'cleanup' }, NOW) + ).toEqual({ 'tab-1': { closedAt: NOW, worktreeId: WT, reason: 'cleanup' } }) }) -} -describe('closed terminal tab tombstones', () => { - it('records the closing worktree and time', () => { - expect(recordClosedTerminalTabTombstone({}, 'tab-1', WT, NOW)).toEqual({ - 'tab-1': { closedAt: NOW, worktreeId: WT } - }) + it('treats a record past the TTL as absent, even before its partition prunes it', () => { + const stale = { + 'tab-1': { closedAt: NOW - CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS - 1, worktreeId: WT } + } + expect(hasClosedTerminalTabRecord(stale, 'tab-1', undefined, NOW)).toBe(false) }) it('prunes past the TTL and caps at the newest entries', () => { @@ -57,53 +49,46 @@ describe('closed terminal tab tombstones', () => { expect(capped['tab-0']).toBeDefined() expect(capped[`tab-${MAX_CLOSED_TERMINAL_TAB_TOMBSTONES + 9}`]).toBeUndefined() }) -}) -describe('closed terminal tab tombstone acknowledgement', () => { - const tombstones = { 'tab-1': { closedAt: NOW, worktreeId: WT } } - - it('a snapshot carrying no revision acknowledges nothing', () => { - expect(reconcile(tombstones, { hostRevision: undefined })).toEqual(tombstones) - }) - - it('a snapshot with no row for the tombstone worktree acknowledges nothing', () => { - const kept = reconcile(tombstones, { acknowledgedWorktreeIds: [], hostRevision: 9 }) - expect(kept['tab-1']).toEqual({ closedAt: NOW, worktreeId: WT }) - }) - - it('the first omitting snapshot only stamps the revision — it could predate the close', () => { - const kept = reconcile(tombstones, { hostRevision: 4 }) - expect(kept['tab-1']).toEqual({ closedAt: NOW, worktreeId: WT, ackRevision: 4 }) - }) - - it('a strictly newer omitting snapshot retires the tombstone', () => { - const stamped = reconcile(tombstones, { hostRevision: 4 }) - expect(reconcile(stamped, { hostRevision: 5 })['tab-1']).toBeUndefined() - }) - - it('a repeat of the same revision does not retire it', () => { - const stamped = reconcile(tombstones, { hostRevision: 4 }) - expect(reconcile(stamped, { hostRevision: 4 })['tab-1']).toBeDefined() - }) - - it('a host that still lists the tab re-arms the watermark instead of retiring', () => { - const stamped = reconcile(tombstones, { hostRevision: 4 }) - const stillListed = reconcile(stamped, { hostRevision: 7, hostKnownTabIds: ['tab-1'] }) - expect(stillListed['tab-1']).toEqual({ closedAt: NOW, worktreeId: WT, ackRevision: 7 }) - expect(reconcile(stillListed, { hostRevision: 7 })['tab-1']).toBeDefined() - expect(reconcile(stillListed, { hostRevision: 8 })['tab-1']).toBeUndefined() - }) - - it('a host revision that went backwards never retires', () => { - const stamped = reconcile(tombstones, { hostRevision: 40 }) - const rewound = reconcile(stamped, { hostRevision: 1 }) - expect(rewound['tab-1']).toEqual({ closedAt: NOW, worktreeId: WT, ackRevision: 40 }) - }) - - it('a tab listed under a different worktree still counts as known to the host', () => { - const stamped = reconcile(tombstones, { hostRevision: 4 }) + it('reads an older build record as a close, and an unknown reason as no reason', () => { + expect(closedTerminalTabTombstoneSchema.parse({ closedAt: 1, worktreeId: WT })).toEqual({ + closedAt: 1, + worktreeId: WT + }) expect( - reconcile(stamped, { hostRevision: 5, hostKnownTabIds: ['tab-1'] })['tab-1'] - ).toBeDefined() + closedTerminalTabTombstoneSchema.parse({ closedAt: 1, worktreeId: WT, reason: 'later' }) + ).toEqual({ closedAt: 1, worktreeId: WT, reason: undefined }) + }) +}) + +describe('emptied on purpose', () => { + const closed: ClosedTerminalTabTombstonesByTabId = { 'tab-1': { closedAt: NOW, worktreeId: WT } } + + it('an empty row with a close record in that workspace was emptied on purpose', () => { + expect( + isTerminalWorkspaceEmptiedOnPurpose( + { tabsByWorktree: { [WT]: [] }, closedTerminalTabTombstonesByTabId: closed }, + WT + ) + ).toBe(true) + }) + + it('an empty row with no record is unknown, as is a missing row', () => { + expect(isTerminalWorkspaceEmptiedOnPurpose({ tabsByWorktree: { [WT]: [] } }, WT)).toBe(false) + expect( + isTerminalWorkspaceEmptiedOnPurpose( + { tabsByWorktree: {}, closedTerminalTabTombstonesByTabId: closed }, + WT + ) + ).toBe(false) + }) + + it('a record for another workspace does not count', () => { + expect( + isTerminalWorkspaceEmptiedOnPurpose( + { tabsByWorktree: { other: [] }, closedTerminalTabTombstonesByTabId: closed }, + 'other' + ) + ).toBe(false) }) }) diff --git a/src/shared/closed-terminal-tab-tombstones.ts b/src/shared/closed-terminal-tab-tombstones.ts index 3db0a585987..6c7b6f81d49 100644 --- a/src/shared/closed-terminal-tab-tombstones.ts +++ b/src/shared/closed-terminal-tab-tombstones.ts @@ -1,18 +1,23 @@ import { z } from 'zod' +import type { RuntimeSessionTabCloseReason } from './runtime-session-contracts' -/** A client's own record that the user closed a terminal tab. +const TERMINAL_TAB_CLOSE_REASONS = [ + 'user', + 'cleanup', + 'pty-exit' +] as const satisfies readonly RuntimeSessionTabCloseReason[] + +/** Main's record that a terminal tab was closed, written by its close transaction only. * - * Why it must exist: absence alone cannot distinguish "the host was never told" from "the user - * closed it", so the merge keeps the tab — and a `pty.kill` that died on the transport means the - * host keeps listing it forever. This is the close signal that outlives the failed RPC. - * Safe because tab ids are uuids: a tombstoned id never legitimately returns. */ + * Why it must exist: absence alone cannot distinguish "never told" from "closed", so without it a + * host snapshot or a late spawn commit brings the tab back, and an emptied workspace reads as one + * that was never initialized. Safe because tab ids are uuids: a closed id never legitimately + * returns. Nothing acknowledges it away; it dies by TTL or the per-host cap. */ export type ClosedTerminalTabTombstone = { closedAt: number worktreeId: string - /** Newest host revision seen for this tab's scope since the close. Retirement needs a STRICTLY - * newer snapshot that omits the tab, so a pull already in flight when the user closed cannot - * acknowledge a close it predates. */ - ackRevision?: number + /** Absent on records an older build wrote, when only user closes were recorded. */ + reason?: RuntimeSessionTabCloseReason } export type ClosedTerminalTabTombstonesByTabId = Record @@ -22,11 +27,12 @@ export type ClosedTerminalTabTombstonesByTabId = Record, now: number ): ClosedTerminalTabTombstonesByTabId { - return pruneClosedTerminalTabTombstones({ ...map, [tabId]: { closedAt: now, worktreeId } }, now) + return pruneClosedTerminalTabTombstones({ ...map, [tabId]: { ...record, closedAt: now } }, now) } -function maxAckRevision(a: number | undefined, b: number | undefined): number | undefined { - if (a === undefined) { - return b - } - return b === undefined ? a : Math.max(a, b) +/** Whether a tab id was closed within the TTL, by the record's own worktree. The TTL is checked + * here because pruning only runs when the partition next records a close. Object.hasOwn because + * the map is a plain object: `in` answers true for every Object.prototype key. */ +export function hasClosedTerminalTabRecord( + map: ClosedTerminalTabTombstonesByTabId | undefined, + tabId: string, + worktreeId?: string, + now = Date.now() +): boolean { + const record = map !== undefined && Object.hasOwn(map, tabId) ? map[tabId] : undefined + return ( + record !== undefined && + now - record.closedAt <= CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS && + (worktreeId === undefined || record.worktreeId === worktreeId) + ) } -export type ClosedTerminalTabTombstoneAck = { - tombstones: ClosedTerminalTabTombstonesByTabId | undefined - /** Worktrees this snapshot actually carries a tab row for. A worktree the snapshot says nothing - * about — including one whose path never resolved to a local id — is not evidence of anything, - * so its tombstones are left untouched. */ - acknowledgedWorktreeIds: ReadonlySet - /** Every tab id the snapshot lists, across all worktrees: an id the host still carries anywhere - * has not been acknowledged, whichever worktree it now sits under. */ - hostKnownTabIds: ReadonlySet - hostRevision: number | undefined - now: number -} - -/** Retires tombstones the host has demonstrably seen, and stamps the rest with the revision that - * proved it had not yet. - * - * Retirement takes a snapshot that both covers the tombstone's worktree and is strictly newer than - * the last one that did — one snapshot alone can be the pull that was already in flight when the - * user closed. Absence never deletes here: a snapshot with no revision, or one carrying no row for - * the worktree, retires nothing. */ -export function reconcileClosedTerminalTabTombstones({ - tombstones, - acknowledgedWorktreeIds, - hostKnownTabIds, - hostRevision, - now -}: ClosedTerminalTabTombstoneAck): ClosedTerminalTabTombstonesByTabId { - const pruned = pruneClosedTerminalTabTombstones(tombstones, now) - if (hostRevision === undefined) { - return pruned - } - const kept: ClosedTerminalTabTombstonesByTabId = {} - for (const [tabId, tombstone] of Object.entries(pruned)) { - if (!acknowledgedWorktreeIds.has(tombstone.worktreeId)) { - kept[tabId] = tombstone - continue - } - const observed = tombstone.ackRevision - if (!hostKnownTabIds.has(tabId) && observed !== undefined && hostRevision > observed) { - continue - } - kept[tabId] = { ...tombstone, ackRevision: maxAckRevision(observed, hostRevision) } - } - return kept +/** Emptied on purpose: the workspace's terminal row exists, is empty, and some tab in it was + * closed within the TTL. An empty row with no live record is unknown (legacy data, an expired + * record, or a writer that is not a close), so it reads as never initialized. Any record + * suffices, which is weaker than "the last removal was a close" until every membership shrink is + * a close. */ +export function isTerminalWorkspaceEmptiedOnPurpose( + state: { + tabsByWorktree: Readonly> + closedTerminalTabTombstonesByTabId?: ClosedTerminalTabTombstonesByTabId + }, + worktreeId: string, + now = Date.now() +): boolean { + return ( + Object.hasOwn(state.tabsByWorktree, worktreeId) && + (state.tabsByWorktree[worktreeId]?.length ?? 0) === 0 && + Object.values(state.closedTerminalTabTombstonesByTabId ?? {}).some( + (record) => + record.worktreeId === worktreeId && + now - record.closedAt <= CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS + ) + ) } diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index fee9a9f1046..2a6ad8c3b7c 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -36,6 +36,7 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { 'codex', 'copilot', 'cursor', + 'dsh', 'kimi', 'muse', 'omp', diff --git a/src/shared/commit-message-agent-specs-secondary.ts b/src/shared/commit-message-agent-specs-secondary.ts index 80dfe37212f..0ad5086bec7 100644 --- a/src/shared/commit-message-agent-specs-secondary.ts +++ b/src/shared/commit-message-agent-specs-secondary.ts @@ -137,6 +137,27 @@ export function buildSecondaryCommitMessageAgentSpecs({ models: [{ id: 'default', label: 'Config default' }], defaultModelId: 'default' }, + dsh: { + id: 'dsh', + label: 'DeepSeek Harness', + binary: 'dsh', + // Why: `dsh --profile headless` runs one fresh persisted session, prints the final + // answer and exits — the documented one-shot entry mode. The interactive `dsh-tui` + // profile is deliberately not used here; Source Control AI stays one-shot. + // Why stdin and not argv: the prompt carries the whole diff. On argv it would sit in + // the process table for every user on the box, and it would eventually hit the argv + // limit. `-` is DSH's explicit stdin marker; measured against 0.1.5-rc.1, omitting the + // positional entirely is rejected ("a task is required") even when stdin is a pipe. + promptDelivery: 'stdin', + buildArgs: () => ['--profile', 'headless', '-'], + // Why: the launcher owns `--profile`; a second one would boot a different profile. + singletonOptions: [['--profile']], + modelSource: 'static', + // Why: the headless app parses no `--model`. The model comes from the profile's + // `llm-deepseek` row, so the only honest choice here is the configured default. + models: [{ id: 'default', label: 'Config default' }], + defaultModelId: 'default' + }, copilot: { id: 'copilot', label: 'GitHub Copilot', diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index 1e586031a4e..f7a9cb7c0c5 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -67,6 +67,16 @@ const DRAFT_PASTE_READY_SIGNALS: Record { + it.each([ + 'dsh web', + 'dsh --profile web', + 'dsh --profile=web --port 8080', + 'dsh --profile headless "run the tests"', + 'dsh --profile sdk', + 'dsh --profile sdk-minimal', + 'dsh --profile acp', + 'dsh plugin --profile dsh-tui add some-plugin', + 'dsh --profile dsh-tui --dump-config' + ])('rejects %s', (commandLine) => { + expect(isDshNonInteractiveCommand(tokens(commandLine))).toBe(true) + }) + + it.each([ + 'dsh --profile dsh-tui', + 'dsh --profile=dsh-tui', + 'dsh --profile dsh-tui --resume 9478e2d8-29bc-4009-ab32-657efa2bd763', + 'dsh-tui', + 'dsh-tui --resume', + 'dst' + ])('accepts %s', (commandLine) => { + expect(isDshNonInteractiveCommand(tokens(commandLine))).toBe(false) + }) + + it('reads only the first --profile, so app arguments cannot retarget it', () => { + // `--resume web` belongs to the terminal app, not the launcher. + expect(isDshNonInteractiveCommand(tokens('dsh --profile dsh-tui --resume web'))).toBe(false) + }) + + it.each([ + 'dsh-tui web', + 'dst web', + 'dsh-tui plugin', + 'dsh-tui --continue web', + 'dsh-tui /home/dev/projects/web', + 'node /usr/local/lib/node_modules/.bin/dsh-tui web', + 'C:\\Users\\dev\\AppData\\npm\\dsh-tui.cmd web' + ])('keeps %s interactive: the launcher already chose the TUI profile', (commandLine) => { + // Everything after `dsh-tui`/`dst` is the terminal app's argv — a workspace target or a + // resume id — so a folder named `web` or `plugin` is a directory name, not a subcommand. + expect(isDshNonInteractiveCommand(tokens(commandLine))).toBe(false) + }) +}) + +describe('dsh foreground process recognition', () => { + it.each([ + 'dsh-tui', + 'dst', + '/opt/homebrew/bin/dsh --profile dsh-tui', + 'node /usr/local/lib/node_modules/.bin/dsh-tui', + 'node /home/dev/.dsh/profiles/dsh-tui/node_modules/@deepseek-harness-tui/dsh-tui/bin/dsh-tui.js', + 'node /usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js --profile dsh-tui' + ])('recognizes %s as dsh', (commandLine) => { + expect(recognizeAgentProcessFromCommandLine(commandLine)?.agent).toBe('dsh') + }) + + it.each([ + 'dsh web', + 'dsh --profile headless "summarize the diff"', + 'node /usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js --profile acp' + ])('does not claim %s as an interactive agent pane', (commandLine) => { + expect(recognizeAgentProcessFromCommandLine(commandLine)).toBeNull() + }) + + it.each(['dsh-tui web', 'dst plugin'])( + 'still recognizes %s as the dsh agent pane', + (commandLine) => { + expect(recognizeAgentProcessFromCommandLine(commandLine)?.agent).toBe('dsh') + } + ) + + it('still reports non-interactive dsh when headless one-shots are included', () => { + // Non-interactivity guards ask for the wider set: a `dsh web` pane is not a shell + // either, so callers that protect agent panes from reuse must still see it. + expect( + recognizeAgentProcessFromCommandLine('dsh --profile headless "go"', { + includeHeadlessOneShot: true + })?.agent + ).toBe('dsh') + }) +}) diff --git a/src/shared/dsh-launch-command.ts b/src/shared/dsh-launch-command.ts new file mode 100644 index 00000000000..60100a9823b --- /dev/null +++ b/src/shared/dsh-launch-command.ts @@ -0,0 +1,115 @@ +// DeepSeek Harness ships one binary, `dsh`, and boots a *profile* with it. Only the +// `dsh-tui` profile paints an interactive composer; `web` serves HTTP, `headless` answers +// one task and exits, `sdk`/`sdk-minimal`/`acp` speak JSON-RPC on stdio, and `plugin` is +// package management. All five are `dsh` in the process table, so foreground recognition +// has to read the profile to tell an agent pane from a server or a one-shot. +// +// `dsh-tui` (and its `dst` alias) forward straight to `dsh --profile dsh-tui`, so they are +// always interactive and never reach this matcher. + +/** Profiles that boot something other than the interactive terminal UI. */ +const NON_INTERACTIVE_PROFILES = new Set([ + 'web', + 'headless', + 'sdk', + 'sdk-minimal', + 'acp', + 'desktop' +]) + +/** Bare-word subcommands the launcher accepts: `plugin` boots no profile at all, and `web` + * is the documented alias for `--profile web`. Neither hosts an interactive pane. */ +const SUBCOMMANDS = new Set(['plugin', 'web']) + +/** Launcher flags that print a composed config and exit. */ +const DUMP_FLAGS = new Set(['--dump-config', '--dump-default-config', '--dump-config-schema']) + +/** Binaries that always boot `--profile dsh-tui` and forward the rest to the terminal app. */ +const TUI_LAUNCHER_NAMES = new Set(['dsh-tui', 'dst']) + +const PROGRAM_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1|js|mjs|cjs)$/ + +function programBasename(token: string | undefined): string { + const unquoted = token?.trim().replace(/^["']|["']$/g, '') ?? '' + const basename = unquoted.split(/[\\/]/).pop() ?? unquoted + return basename.toLowerCase().replace(PROGRAM_EXTENSION_RE, '') +} + +function readProfileName(tokens: readonly string[], index: number): string | null { + const token = tokens[index] + if (token === undefined) { + return null + } + if (token.startsWith('--profile=')) { + return token.slice('--profile='.length) + } + return token === '--profile' ? (tokens[index + 1] ?? null) : null +} + +/** Launcher flags that take a value, so the token after them is never an app argument. */ +const LAUNCHER_FLAGS_WITH_VALUE = new Set(['--profile', '--from-default-profile', '--patch']) + +/** Valueless launcher flags. */ +const LAUNCHER_FLAGS = new Set(['-V', '--version', '-h', '--help', ...DUMP_FLAGS]) + +/** Splits `--profile=web` down to `--profile` so both spellings match one lookup. */ +function flagName(token: string): string { + return token.split('=', 1)[0] +} + +function isLauncherToken(token: string): boolean { + return LAUNCHER_FLAGS.has(token) || LAUNCHER_FLAGS_WITH_VALUE.has(flagName(token)) +} + +/** + * Whether a `dsh` command line runs something other than the interactive agent. + * + * Only the launcher's own tokens are read. Everything after the first token the launcher + * does not recognize belongs to the booted app (`dsh --profile dsh-tui --resume `), + * and a prompt or session id is free text that must never be read as a launcher flag. + */ +export function isDshNonInteractiveCommand(tokens: readonly string[]): boolean { + // Why first: `dsh-tui`/`dst` have already chosen the interactive profile, and everything + // after them is the TERMINAL APP's argv — a `--resume` id or a workspace target. A + // workspace folder named `web` or `plugin` is an ordinary directory name, and reading it + // as a `dsh` subcommand would mark a live agent pane non-interactive, costing it status + // hooks and prompt delivery. Index 1 as well as 0 because a node shim puts the launcher + // script path there. + if ( + TUI_LAUNCHER_NAMES.has(programBasename(tokens[0])) || + TUI_LAUNCHER_NAMES.has(programBasename(tokens[1])) + ) { + return false + } + let profile: string | null = null + let index = 1 + // Skip the leading non-flag tokens: an interpreter invocation puts the script path here. + while (index < tokens.length && !isLauncherToken(tokens[index])) { + const token = tokens[index] + if (SUBCOMMANDS.has(token)) { + return true + } + if (!token.startsWith('-') && index > 1) { + // A bare word that is neither a subcommand nor a flag: the app's arguments start here. + return false + } + index += 1 + } + for (; index < tokens.length; index += 1) { + const token = tokens[index] + if (DUMP_FLAGS.has(token)) { + return true + } + if (!isLauncherToken(token)) { + break + } + const explicitProfile = readProfileName(tokens, index) + if (explicitProfile !== null && profile === null) { + profile = explicitProfile + } + if (LAUNCHER_FLAGS_WITH_VALUE.has(token)) { + index += 1 + } + } + return profile !== null && NON_INTERACTIVE_PROFILES.has(profile) +} diff --git a/src/shared/dsh-terminal-title.test.ts b/src/shared/dsh-terminal-title.test.ts new file mode 100644 index 00000000000..ab28f4d56f4 --- /dev/null +++ b/src/shared/dsh-terminal-title.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { detectAgentStatusFromTitle, normalizeTerminalTitle } from './agent-title-status' +import { isDshTerminalTitle, isGeminiTerminalTitle } from './agent-title-core' +import { getAgentLabel, resolveExplicitTerminalTitleAgentType } from './terminal-title-agent-type' + +// DSH-TUI titles are ` 🐋 `: `✦` at rest, `⠂`/`⠐` while a turn +// runs. Both halves collide with an existing agent — `✦` is Gemini CLI's WORKING glyph, +// and the braille frames are what Claude's generic heuristic claims — so the whale has to +// outrank both. Evidence: src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt. +const RESTING = '✦ \u{1F40B} dsh-demo-repo' +const WORKING_FRAMES = ['⠂ \u{1F40B} fix the flaky test', '⠐ \u{1F40B} fix the flaky test'] + +describe('a DSH-TUI terminal title', () => { + it('is recognized by its whale', () => { + expect(isDshTerminalTitle(RESTING)).toBe(true) + expect(isDshTerminalTitle('✦ some other agent')).toBe(false) + }) + + it('is never read as Gemini CLI', () => { + expect(isGeminiTerminalTitle(RESTING)).toBe(false) + expect(getAgentLabel(RESTING)).toBe('DeepSeek Harness') + expect(resolveExplicitTerminalTitleAgentType(RESTING)).toBe('dsh') + }) + + it('survives normalization instead of being rewritten to Gemini', () => { + expect(normalizeTerminalTitle(RESTING)).toBe(RESTING) + }) + + it('does not report working just because it carries Gemini’s working glyph', () => { + // The regression this guards: a finished DSH pane reported working forever, because + // `✦` is DSH's REST prefix and Gemini's WORKING glyph. + expect(detectAgentStatusFromTitle(RESTING)).toBeNull() + }) + + it('keeps its working frames out of Claude’s braille lane', () => { + for (const frame of WORKING_FRAMES) { + expect(getAgentLabel(frame)).toBe('DeepSeek Harness') + } + }) + + it('leaves a real Gemini title alone', () => { + expect(isGeminiTerminalTitle('✦ gemini')).toBe(true) + expect(normalizeTerminalTitle('✦ gemini')).toBe('✦ Gemini CLI') + }) +}) diff --git a/src/shared/dsh-terminal-title.ts b/src/shared/dsh-terminal-title.ts new file mode 100644 index 00000000000..76662bd10d0 --- /dev/null +++ b/src/shared/dsh-terminal-title.ts @@ -0,0 +1,10 @@ +/** DSH-TUI titles are always ` 🐋 ` (its Chat screen's + * `useTerminalTitle`), and the whale is the only part no other agent emits. It has to + * outrank the Gemini glyphs below: DSH's IDLE prefix is `✦`, which is Gemini's WORKING + * glyph, so without this a resting DSH pane reads as a working Gemini — wrong agent, + * wrong state. Evidence: src/main/runtime/__fixtures__/dsh-tui-ready-no-key.txt. */ +export const DSH_WHALE = '\u{1F40B}' // 🐋 + +export function isDshTerminalTitle(title: string): boolean { + return title.includes(DSH_WHALE) +} diff --git a/src/shared/freebuff-launch.test.ts b/src/shared/freebuff-launch.test.ts new file mode 100644 index 00000000000..b58dc4bd091 --- /dev/null +++ b/src/shared/freebuff-launch.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup' +import { isExpectedAgentProcess, recognizeAgentProcess } from './agent-process-recognition' +import { pickTuiAgent } from './tui-agent-selection' + +describe('Freebuff terminal launches', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'delivers task text through the interactive terminal on %s', + (platform) => { + const prompt = '--help\nFix "quoted" paths and $HOME handling' + const plan = buildAgentStartupPlan({ + agent: 'freebuff', + prompt, + cmdOverrides: {}, + platform + }) + expect(plan).toMatchObject({ + launchCommand: 'freebuff', + expectedProcess: 'freebuff', + followupPrompt: prompt + }) + expect( + buildAgentDraftLaunchPlan({ + agent: 'freebuff', + draft: prompt, + cmdOverrides: {}, + platform + }) + ).toBeNull() + } + ) + + it('uses the same launch contract on SSH hosts', () => { + expect( + buildAgentStartupPlan({ + agent: 'freebuff', + prompt: 'Inspect this folder', + cmdOverrides: {}, + platform: 'linux', + isRemote: true + }) + ).toMatchObject({ launchCommand: 'freebuff', followupPrompt: 'Inspect this folder' }) + }) + + it.each(['/usr/local/bin/freebuff', 'C:\\tools\\freebuff.exe'])( + 'recognizes %s separately from Codebuff', + (processName) => { + expect(recognizeAgentProcess(processName)?.agent).toBe('freebuff') + expect(isExpectedAgentProcess(processName, 'codebuff')).toBe(false) + } + ) + + it('honors independent installation and disabled-agent preferences', () => { + expect(pickTuiAgent('freebuff', ['freebuff', 'codebuff'])).toBe('freebuff') + expect(pickTuiAgent(null, ['freebuff'])).toBe('freebuff') + expect(pickTuiAgent('freebuff', ['freebuff', 'codebuff'], ['freebuff'])).toBe('codebuff') + }) +}) diff --git a/src/shared/freebuff-screen-status.ts b/src/shared/freebuff-screen-status.ts new file mode 100644 index 00000000000..e5f18472a7d --- /dev/null +++ b/src/shared/freebuff-screen-status.ts @@ -0,0 +1,152 @@ +import type { ParsedAgentStatusPayload } from './agent-status-types' +import { extractLastOscTitle } from './osc-title-extraction' +import { extractOscTitleScanTail } from './osc-title-scan-tail' +import { advancePartialEscapeTail } from './terminal-partial-escape-tail' + +export function splitFreebuffScreenUpdates(data: string, pendingEscape: string): string[] { + const input = pendingEscape + data + const chunks: string[] = [] + let start = 0 + // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. + for (const match of input.matchAll(/\x1b\[\?2026l/g)) { + const end = match.index + match[0].length - pendingEscape.length + if (end > start) { + chunks.push(data.slice(start, end)) + } + start = Math.max(start, end) + } + if (start < data.length) { + chunks.push(data.slice(start)) + } + return chunks +} + +function cleanBoxLine(line: string): string { + return line.replace(/^[│┃\s]+|[│┃\s]+$/g, '').trim() +} + +/** Reads the live composer area, never status words in the conversation above it. */ +export function readFreebuffScreenStatus( + lines: readonly string[], + alternate: boolean +): Omit | null { + const text = lines.join('\n') + if (!alternate) { + if ( + /freebuff found agent files in this repository/.test(text) && + /Load and run these\? \[y\/N\]/.test(text) + ) { + return { state: 'blocked', toolInput: 'Trust repository agent files? [y/N]' } + } + return null + } + if (/Press ENTER to login\.\.\./.test(text)) { + return { state: 'blocked', toolInput: 'Sign in to Freebuff' } + } + const footer = lines.findLastIndex((line) => /\/model\s+to change.*Chat:/.test(line)) + if (footer === -1) { + return null + } + const dialog = lines.findLastIndex((line) => /[╭─].*Some questions for you.*[─╮]/.test(line)) + if ( + dialog !== -1 && + dialog < footer && + /↑↓ navigate/.test(lines.slice(dialog, footer).join('\n')) + ) { + const body = lines.slice(dialog + 1, footer).map(cleanBoxLine) + const question = body.find((line) => /^[▼▶▸▾►]/.test(line))?.replace(/^[▼▶▸▾►]\s*/, '') + const options = body.flatMap((line) => { + const match = /^[○●]\s+(.+)$/.exec(line) + return match ? [{ label: match[1], description: '' }] : [] + }) + return { + state: 'waiting', + toolName: 'ask_user', + toolInput: question ?? 'Freebuff is waiting for an answer', + ...(question + ? { interactivePrompt: JSON.stringify({ questions: [{ question, options }] }) } + : {}) + } + } + const composerTop = lines.findLastIndex( + (line, index) => index < footer && /^\s*╭─+╮\s*$/.test(line) + ) + if (composerTop === -1 || footer - composerTop > 12) { + return null + } + const status = lines[composerTop - 1]?.trim() ?? '' + if ( + /^(?:thinking|working|connecting|retrying)\.\.\./.test(status) || + status.startsWith('high demand — in line') + ) { + return { state: 'working' } + } + if (status === '' && text.includes('Your first message starts the session.')) { + return { state: 'done', sessionBoundary: true } + } + if (/^(?:\d+[hms]\s*)+left\b|^unlimited\b/.test(status) && /End session/.test(status)) { + return { state: 'done' } + } + return null +} + +/** One observation stream per execution-host terminal; recreated with its screen model. */ +export class FreebuffScreenStatusTracker { + private titleTail = '' + private frameTail = '' + private insideFrame = false + private identified: boolean + private prompt = '' + private previous: string | null = null + private worked = false + + constructor(identified = false) { + this.identified = identified + } + + observe( + data: string, + readScreen: () => { lines: readonly string[]; alternate: boolean } + ): ParsedAgentStatusPayload | null { + const titleInput = this.titleTail + data + this.titleTail = extractOscTitleScanTail(titleInput) + const title = extractLastOscTitle(titleInput) + if (title?.startsWith('Freebuff: ')) { + this.identified = true + this.prompt = title.slice('Freebuff: '.length) + } + const frameInput = this.frameTail + data + // oxlint-disable-next-line no-control-regex -- Terminal protocol delimiters contain ESC and BEL. + for (const match of frameInput.matchAll(/\x1b\[\?2026([hl])/g)) { + this.insideFrame = match[1] === 'h' + } + this.frameTail = advancePartialEscapeTail(this.frameTail, data) + if (!this.identified || this.insideFrame || this.frameTail.length > 0) { + return null + } + const { lines, alternate } = readScreen() + const result = readFreebuffScreenStatus(lines, alternate) + if (!result) { + return null + } + if (result.sessionBoundary) { + this.prompt = '' + this.worked = false + } + const payload: ParsedAgentStatusPayload = { + ...result, + agentType: 'freebuff', + prompt: this.prompt, + ...(result.state === 'done' && !this.worked ? { sessionBoundary: true } : {}) + } + if (result.state === 'working' || result.state === 'waiting') { + this.worked = true + } + const signature = JSON.stringify(payload) + if (signature === this.previous) { + return null + } + this.previous = signature + return payload + } +} diff --git a/src/shared/freebuff-startup.test.ts b/src/shared/freebuff-startup.test.ts new file mode 100644 index 00000000000..2d614c53d1a --- /dev/null +++ b/src/shared/freebuff-startup.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan } from './tui-agent-startup' + +describe('Freebuff startup', () => { + it.each([ + ['darwin', 'posix', false], + ['linux', 'posix', false], + ['win32', 'powershell', false], + ['win32', 'cmd', false], + ['linux', 'posix', true] + ] as const)('delivers prompts after launch on %s/%s (remote=%s)', (platform, shell, isRemote) => { + const prompt = 'Review "quoted paths"; $(echo task)\nThen explain the changes.' + const plan = buildAgentStartupPlan({ + agent: 'freebuff', + prompt, + cmdOverrides: { codebuff: 'codebuff --debug' }, + platform, + shell, + isRemote + }) + + expect(plan).toMatchObject({ + agent: 'freebuff', + launchCommand: 'freebuff', + expectedProcess: 'freebuff', + followupPrompt: prompt + }) + }) + + it('keeps Freebuff command overrides separate from Codebuff', () => { + const cmdOverrides = { freebuff: 'freebuff --debug' } + const freebuff = buildAgentStartupPlan({ + agent: 'freebuff', + prompt: '', + allowEmptyPromptLaunch: true, + cmdOverrides, + platform: 'linux', + isRemote: true + }) + const codebuff = buildAgentStartupPlan({ + agent: 'codebuff', + prompt: '', + allowEmptyPromptLaunch: true, + cmdOverrides, + platform: 'linux', + isRemote: true + }) + + expect(freebuff).toMatchObject({ + launchCommand: 'freebuff --debug', + expectedProcess: 'freebuff', + followupPrompt: null + }) + expect(codebuff).toMatchObject({ launchCommand: 'codebuff', expectedProcess: 'codebuff' }) + }) +}) diff --git a/src/shared/mobile-web-bundle/manifest-contract.test.ts b/src/shared/mobile-web-bundle/manifest-contract.test.ts index c072b9de63b..089fb9c9149 100644 --- a/src/shared/mobile-web-bundle/manifest-contract.test.ts +++ b/src/shared/mobile-web-bundle/manifest-contract.test.ts @@ -11,6 +11,7 @@ import { MOBILE_WEB_BUNDLE_MAX_ROUTES, MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES, MOBILE_WEB_BUNDLE_SCHEMA_VERSION, + MOBILE_WEB_PAGE_VERSION, type MobileWebBundleAsset } from './manifest-contract' @@ -104,6 +105,24 @@ describe('MobileWebBundleManifestSchema', () => { }) }) +describe('the page version a manifest carries', () => { + it('takes a count, which is what a shell floors on', () => { + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf([ENTRY], { pageVersion: 1 })).success + ).toBe(true) + for (const bad of [-1, 1.5, '1']) { + expect( + MobileWebBundleManifestSchema.safeParse(manifestOf([ENTRY], { pageVersion: bad })).success, + String(bad) + ).toBe(false) + } + }) + + it('is at least 1, so a manifest without one reads as older than every page that has one', () => { + expect(MOBILE_WEB_PAGE_VERSION).toBeGreaterThanOrEqual(1) + }) +}) + describe('the page routes a manifest declares', () => { function withRoutes(routes: unknown): boolean { return MobileWebBundleManifestSchema.safeParse(manifestOf([ENTRY], { routes })).success diff --git a/src/shared/mobile-web-bundle/manifest-contract.ts b/src/shared/mobile-web-bundle/manifest-contract.ts index 644c0dc1e09..e12443cb7a2 100644 --- a/src/shared/mobile-web-bundle/manifest-contract.ts +++ b/src/shared/mobile-web-bundle/manifest-contract.ts @@ -4,6 +4,12 @@ import { sha256 } from '../sha256' /** A reader that sees another value must reject rather than guess at the shape. */ export const MOBILE_WEB_BUNDLE_SCHEMA_VERSION = 1 as const +/** + * The page's build number, written into every manifest. A shell requires a page at least as new as + * its own floor and walls an older desktop; bump this when a shell stops handling an older page. + */ +export const MOBILE_WEB_PAGE_VERSION = 1 + /** The only stable-named asset, and the only one that references the content-addressed names. */ export const MOBILE_WEB_BUNDLE_ENTRYPOINT = 'index.html' @@ -259,6 +265,8 @@ export const MobileWebBundleManifestSchema = z desktopVersion: z.string().min(1).max(MAX_DESKTOP_VERSION_LENGTH), minCompatibleRuntimeProtocolVersion: z.number().int().nonnegative(), runtimeProtocolVersion: z.number().int().nonnegative(), + /** `MOBILE_WEB_PAGE_VERSION` at build time; absent from a desktop older than the field. */ + pageVersion: z.number().int().nonnegative().optional(), entrypoint: z.literal(MOBILE_WEB_BUNDLE_ENTRYPOINT), totalBytes: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_TOTAL_BYTES), assets: z.array(MobileWebBundleAssetSchema).min(1).max(MOBILE_WEB_BUNDLE_MAX_ASSETS), diff --git a/src/shared/native-chat-empty-state.ts b/src/shared/native-chat-empty-state.ts index 2797648afd0..3ef7b1a1902 100644 --- a/src/shared/native-chat-empty-state.ts +++ b/src/shared/native-chat-empty-state.ts @@ -19,6 +19,11 @@ export const NATIVE_CHAT_EMPTY_STATE_COPY = { title: 'Could not load conversation', subtitle: 'The transcript could not be read. Toggle back to the terminal to keep working.' }, + // The structured chat's read reconnects on its own; the terminal-backed read does not. + retryingError: { + title: 'Could not load conversation', + subtitle: 'The transcript could not be read. Orca keeps trying to load it.' + }, notAgent: { title: 'No conversation here', subtitle: 'This terminal is not running a recognized coding agent.' diff --git a/src/shared/native-chat-transcript-projection.ts b/src/shared/native-chat-transcript-projection.ts index 26b21824711..45f2488f4aa 100644 --- a/src/shared/native-chat-transcript-projection.ts +++ b/src/shared/native-chat-transcript-projection.ts @@ -4,6 +4,7 @@ // of exactly what the renderer's transcript runs, not a second reading of it. import type { NativeChatMessage } from './native-chat-types' +import { compareAgentJournalPositions } from './agent-session-journal-position' import { stripNoiseMessages } from './native-chat-noise' import { foldToolMessages } from './native-chat-tool-fold' @@ -27,11 +28,32 @@ export function compareNativeChatMessagesByTime( return 0 } +/** Rows the journal holds read in the journal's own order, never its clock: a + * batch shares one timestamp, and a row recovered after a crash carries an + * earlier one. A row not in the journal yet — a send still in the outbox — was + * made after everything the journal holds, so it follows them; only such rows, + * and terminal-backed transcripts, which have no journal, order by time. */ +export function compareNativeChatTranscriptMessages( + a: NativeChatMessage, + b: NativeChatMessage +): number { + if (a.journalPosition && b.journalPosition) { + return compareAgentJournalPositions(a.journalPosition, b.journalPosition) + } + if (a.journalPosition || b.journalPosition) { + return a.journalPosition ? -1 : 1 + } + return compareNativeChatMessagesByTime(a, b) +} + /** `compare` lets the renderer order its own tail rows (streaming, optimistic * sends), which never exist on the host. */ export function projectNativeChatTranscriptMessages( messages: readonly NativeChatMessage[], - compare: (a: NativeChatMessage, b: NativeChatMessage) => number = compareNativeChatMessagesByTime + compare: ( + a: NativeChatMessage, + b: NativeChatMessage + ) => number = compareNativeChatTranscriptMessages ): NativeChatMessage[] { // Not `toSorted`: mobile's Hermes lacks it, and src/shared must stay loadable there. return stripNoiseMessages(foldToolMessages(Array.from(messages).sort(compare))) diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 8157676a22c..e36eaa86fd3 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -32,81 +32,66 @@ describe('formatNativeChatDuration', () => { }) describe('describeNativeChatTurnStatus', () => { - it('prefers the settled duration over the thinking and counting labels', () => { - expect( - describeNativeChatTurnStatus({ thinking: true, workedSeconds: 184, elapsedSeconds: 9 }) - ).toEqual({ key: 'workedFor', duration: '3m 4s' }) + it('prefers the settled duration over the running clock', () => { + expect(describeNativeChatTurnStatus({ workedSeconds: 184, elapsedSeconds: 9 })).toEqual({ + key: 'workedFor', + duration: '3m 4s' + }) }) - it('reports thinking before the turn produces output', () => { - expect( - describeNativeChatTurnStatus({ thinking: true, workedSeconds: null, elapsedSeconds: 9 }) - ).toEqual({ key: 'thinking', duration: null }) - }) - - it('counts once the turn has output', () => { - expect( - describeNativeChatTurnStatus({ thinking: false, workedSeconds: null, elapsedSeconds: 12 }) - ).toEqual({ key: 'workingFor', duration: '12s' }) + it('counts from the first second of the turn', () => { + expect(describeNativeChatTurnStatus({ workedSeconds: null, elapsedSeconds: 0 })).toEqual({ + key: 'workingFor', + duration: '0s' + }) }) }) describe('describeNativeChatActiveTurnLabel', () => { it('lets provider activity beat both fallbacks', () => { expect( - describeNativeChatActiveTurnLabel({ - activityText: 'Reading src/main.ts', - thinking: true, - elapsedSeconds: 12 - }) + describeNativeChatActiveTurnLabel({ activityText: 'Reading src/main.ts', thinking: true }) ).toEqual({ source: 'activity', text: 'Reading src/main.ts' }) }) it('falls back to reasoning when the provider says nothing usable', () => { - expect( - describeNativeChatActiveTurnLabel({ activityText: ' ', thinking: true, elapsedSeconds: 12 }) - ).toEqual({ source: 'status', key: 'thinking', duration: null }) - expect( - describeNativeChatActiveTurnLabel({ activityText: null, thinking: true, elapsedSeconds: 12 }) - ).toEqual({ source: 'status', key: 'thinking', duration: null }) + expect(describeNativeChatActiveTurnLabel({ activityText: ' ', thinking: true })).toEqual({ + source: 'status', + key: 'thinking' + }) + expect(describeNativeChatActiveTurnLabel({ activityText: null, thinking: true })).toEqual({ + source: 'status', + key: 'thinking' + }) }) - it('falls back to the running clock when the turn is neither talking nor reasoning', () => { - expect(describeNativeChatActiveTurnLabel({ thinking: false, elapsedSeconds: 184 })).toEqual({ + // The clock belongs to the turn bar; repeating it here is two rows saying "Working for". + it('falls back to plain working, never the clock, when neither applies', () => { + expect(describeNativeChatActiveTurnLabel({ thinking: false })).toEqual({ source: 'status', - key: 'workingFor', - duration: '3m 4s' + key: 'working' }) }) }) describe('formatNativeChatActiveTurnLabel', () => { - it('renders the one live row in English for platforms without i18n', () => { + it('renders the live tail line in English for platforms without i18n', () => { expect( - formatNativeChatActiveTurnLabel({ - activityText: 'Running pnpm test', - thinking: false, - elapsedSeconds: 4 - }) + formatNativeChatActiveTurnLabel({ activityText: 'Running pnpm test', thinking: false }) ).toBe('Running pnpm test') - expect(formatNativeChatActiveTurnLabel({ thinking: true, elapsedSeconds: 4 })).toBe('Thinking') - expect(formatNativeChatActiveTurnLabel({ thinking: false, elapsedSeconds: 12 })).toBe( - 'Working for 12s' - ) + expect(formatNativeChatActiveTurnLabel({ thinking: true })).toBe('Thinking') + expect(formatNativeChatActiveTurnLabel({ thinking: false })).toBe('Working…') }) }) describe('formatNativeChatTurnStatusLabel', () => { - it('renders each state in English for platforms without i18n', () => { - expect( - formatNativeChatTurnStatusLabel({ thinking: true, workedSeconds: null, elapsedSeconds: 0 }) - ).toBe('Thinking') - expect( - formatNativeChatTurnStatusLabel({ thinking: false, workedSeconds: null, elapsedSeconds: 12 }) - ).toBe('Working for 12s') - expect( - formatNativeChatTurnStatusLabel({ thinking: false, workedSeconds: 184, elapsedSeconds: 0 }) - ).toBe('Worked for 3m 4s') + it('renders the turn bar in English for platforms without i18n', () => { + expect(formatNativeChatTurnStatusLabel({ workedSeconds: null, elapsedSeconds: 12 })).toBe( + 'Working for 12s' + ) + expect(formatNativeChatTurnStatusLabel({ workedSeconds: 184, elapsedSeconds: 0 })).toBe( + 'Worked for 3m 4s' + ) }) }) @@ -380,6 +365,24 @@ describe('selectNativeChatTurnStatuses', () => { ) expect(completedByTurn).toEqual({}) }) + + it('keeps the just-ended turn live until its local duration is stamped', () => { + const running = { u1: { startedAt: 1_000, workedSeconds: null } } + const { active } = selectNativeChatTurnStatuses(running, { + activeTurnKey: 'u1', + isWorking: false, + thinking: true + }) + expect(active).toEqual({ startedAt: 1_000, thinking: false, workedSeconds: null }) + // The host saying it never saw the end still wins. + const unverifiable = selectNativeChatTurnStatuses(running, { + activeTurnKey: 'u1', + isWorking: false, + thinking: false, + settledByTurn: new Map([['u1', null]]) + }) + expect(unverifiable.active).toBeNull() + }) }) describe('nativeChatElapsedSeconds', () => { diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index 9f942ad7e21..1b0613bd716 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -1,10 +1,12 @@ -// Turn-status derivation and copy for the native-chat "Thinking / Working for N / -// Worked for N" row, shared by the desktop renderer (as its i18n fallback strings) -// and the mobile app (used directly — mobile ships English only) so the two -// surfaces never drift. Everything here is pure; each platform owns its own clock. +// Turn-status derivation and copy for the native-chat turn rows — the "Working for N / +// Worked for N" bar under the user's message and the live line at the turn's tail — +// shared by the desktop renderer (as its i18n fallback strings) and the mobile app +// (used directly — mobile ships English only) so the two surfaces never drift. +// Everything here is pure; each platform owns its own clock. export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', + working: 'Working…', workingFor: 'Working for {{value0}}', workedFor: 'Worked for {{value0}}', toggleDetails: 'Toggle turn details', @@ -26,28 +28,22 @@ export function formatNativeChatDuration(seconds: number): string { return `${hours}h ${minutes % 60}m ${remainingSeconds}s` } -/** Which of the three copy keys a turn-status row renders, and its duration - * argument. Desktop maps this onto `translate`; mobile formats it directly. */ +/** The turn bar's copy key and duration: the running clock, then the settled one. + * Desktop maps this onto `translate`; mobile formats it directly. */ export function describeNativeChatTurnStatus({ - thinking, workedSeconds, elapsedSeconds }: { - thinking: boolean workedSeconds?: number | null elapsedSeconds: number -}): { key: 'thinking' | 'workingFor' | 'workedFor'; duration: string | null } { - if (workedSeconds != null) { - return { key: 'workedFor', duration: formatNativeChatDuration(workedSeconds) } - } - if (thinking) { - return { key: 'thinking', duration: null } - } - return { key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) } +}): { key: 'workingFor' | 'workedFor'; duration: string } { + return workedSeconds != null + ? { key: 'workedFor', duration: formatNativeChatDuration(workedSeconds) } + : { key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) } } -/** The two readings that label a live turn's one indicator row, carried together - * so a surface cannot pick up one without the other. */ +/** The two readings that label a live turn's tail line, carried together so a + * surface cannot pick up one without the other. */ export type NativeChatLiveTurnIndicator = { thinking: boolean activityText: string | null @@ -55,52 +51,41 @@ export type NativeChatLiveTurnIndicator = { export type NativeChatActiveTurnLabel = | { source: 'activity'; text: string } - | { source: 'status'; key: 'thinking' | 'workingFor'; duration: string | null } + | { source: 'status'; key: 'thinking' | 'working' } -/** The live turn's single indicator label. Provider activity wins because it is the - * only text that says what the turn is actually doing; reasoning is next; the - * running clock is the floor. Shared so desktop and mobile cannot disagree. */ +/** The live tail line's label. Provider activity wins because it is the only text + * that says what the turn is actually doing; reasoning is next. It never carries + * the clock — the turn bar owns that. Shared so desktop and mobile cannot disagree. */ export function describeNativeChatActiveTurnLabel({ activityText, - thinking, - elapsedSeconds + thinking }: { activityText?: string | null thinking: boolean - elapsedSeconds: number }): NativeChatActiveTurnLabel { const text = activityText?.trim() if (text) { return { source: 'activity', text } } - return thinking - ? { source: 'status', key: 'thinking', duration: null } - : { source: 'status', key: 'workingFor', duration: formatNativeChatDuration(elapsedSeconds) } + return { source: 'status', key: thinking ? 'thinking' : 'working' } } -/** The live turn's label in English. For platforms without i18n (mobile). */ +/** The live tail line's label in English. For platforms without i18n (mobile). */ export function formatNativeChatActiveTurnLabel(input: { activityText?: string | null thinking: boolean - elapsedSeconds: number }): string { const label = describeNativeChatActiveTurnLabel(input) - if (label.source === 'activity') { - return label.text - } - const copy = NATIVE_CHAT_TURN_STATUS_COPY[label.key] - return label.duration == null ? copy : copy.replaceAll('{{value0}}', label.duration) + return label.source === 'activity' ? label.text : NATIVE_CHAT_TURN_STATUS_COPY[label.key] } -/** Resolve the turn-status label in English. For platforms without i18n (mobile). */ +/** Resolve the turn bar's label in English. For platforms without i18n (mobile). */ export function formatNativeChatTurnStatusLabel(input: { - thinking: boolean workedSeconds?: number | null elapsedSeconds: number }): string { const { key, duration } = describeNativeChatTurnStatus(input) - const copy = NATIVE_CHAT_TURN_STATUS_COPY[key] - return duration == null ? copy : copy.replaceAll('{{value0}}', duration) + return NATIVE_CHAT_TURN_STATUS_COPY[key].replaceAll('{{value0}}', duration) } export type NativeChatTurnTiming = { @@ -241,14 +226,20 @@ export function selectNativeChatTurnStatuses( workedSeconds: settled.workedSeconds } } + const activeTiming = timingByTurn[activeTurnKey] return { active: isWorking ? { - startedAt: timingByTurn[activeTurnKey]?.startedAt ?? workingStartedAt ?? null, + startedAt: activeTiming?.startedAt ?? workingStartedAt ?? null, thinking, workedSeconds: null } - : (completedByTurn[activeTurnKey] ?? null), + : (completedByTurn[activeTurnKey] ?? + // Ended, but the local duration is stamped a pass later: stay live until then so + // the bar settles in place instead of blinking out. The host's null still hides it. + (activeTiming?.workedSeconds === null && !settledByTurn?.has(activeTurnKey) + ? { startedAt: activeTiming.startedAt, thinking: false, workedSeconds: null } + : null)), completedByTurn } } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 24bf8652fb2..f33158d9a0f 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -10,7 +10,11 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskRunState } from './agent-session-background-task-wire' -import type { AgentJournalMessageSendMode } from './agent-session-journal-types' +import type { AgentSessionFailureFact } from './agent-session-failure' +import type { + AgentJournalMessageSendMode, + AgentJournalPosition +} from './agent-session-journal-types' import type { AgentType } from './agent-status-types' import type { NativeChatToolMetadata } from './native-chat-tool-identity' @@ -51,6 +55,8 @@ export type NativeChatTextBlock = { truncated: boolean } } + /** On a status line that reports a failure: what failed, typed. */ + failure?: AgentSessionFailureFact } /** A tool invocation by the agent. `input` is the (already-serialized) tool @@ -199,6 +205,9 @@ export type NativeChatMessage = { turnId?: string /** How a user message was delivered when it was not an ordinary prompt. */ sentAs?: AgentJournalMessageSendMode + /** Set only by the structured projection, on rows the journal holds, and ranks + * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ + journalPosition?: AgentJournalPosition } export const NATIVE_CHAT_TURN_LIFECYCLE_STATES = ['working', 'completed', 'interrupted'] as const diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 6b1f20b58b2..293efe942e4 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -120,6 +120,8 @@ export type PersistedUIState = { _grokStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Cursor status item. */ _cursorStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on ZCode status item. */ + _zcodeStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index dd992e32ea5..bd8b3f62a31 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -167,7 +167,8 @@ export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = 'agent-session.pending-send-result.v1' as const // Why: a send is now answered once the host accepts it, before any agent has it. A client without // this cannot show a message rejected after that answer, so the host holds its reply until the -// message is handed over or rejected. +// message is handed over or rejected. Transitional: drop the hold once no supported desktop or +// mobile client lacks the capability; mobile must first show a rejected message in place. export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = 'agent-session.accepted-send.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific @@ -234,6 +235,9 @@ export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi- export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY = 'agent-session.opencode2-resume.v1' as const export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const +export const AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY = 'agent-session.dsh-resume.v1' as const +export const AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY = + 'agent-session.qoder-resume.v1' as const export const AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY = 'agent-session.zcode-resume.v1' as const // Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. @@ -396,6 +400,8 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_DSH_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_QODER_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, diff --git a/src/shared/qoder-agent.test.ts b/src/shared/qoder-agent.test.ts new file mode 100644 index 00000000000..8db665e7209 --- /dev/null +++ b/src/shared/qoder-agent.test.ts @@ -0,0 +1,160 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + recognizeAgentProcessFromCommandLine, + isExpectedAgentProcess +} from './agent-process-recognition' +import { + getAgentLabel, + isGeminiTerminalTitle, + normalizeTerminalTitle, + detectAgentStatusFromTitle +} from './agent-detection' +import { buildAgentStartupPlan } from './tui-agent-startup' +import { getAgentResumeArgv } from './agent-session-resume' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { normalizeAndAccept } from './agent-hook-listener-test-harness' + +describe('Qoder agent identity and lifecycle', () => { + it('recognizes the installed versioned binary on native platforms and excludes headless runs', () => { + for (const command of [ + 'qodercli', + '/home/dev/.qoder/bin/qodercli/qodercli-1.1.64', + 'C:\\Qoder\\qodercli-1.1.64.exe' + ]) { + expect(recognizeAgentProcessFromCommandLine(command)?.agent).toBe('qoder') + expect(isExpectedAgentProcess(command, 'qodercli')).toBe(true) + } + expect(recognizeAgentProcessFromCommandLine('qodercli --print hello')).toBeNull() + for (const args of [ + '-o json', + '--input-format=stream-json', + '--remote-control session', + '--acp', + '--delete-session 1' + ]) { + expect(recognizeAgentProcessFromCommandLine(`qodercli ${args}`)).toBeNull() + } + expect(recognizeAgentProcessFromCommandLine('qodercli --prompt-interactive hello')?.agent).toBe( + 'qoder' + ) + expect(recognizeAgentProcessFromCommandLine('qodercli-unrelated')).toBeNull() + }) + + it.each([ + ['◇ Qoder CLI | Ready', 'idle'], + ['✦ Implement feature | Thinking', 'working'], + ['▲ Implement feature | Confirm', 'permission'] + ] as const)('keeps %s attributed through title storage', (title, status) => { + const stored = normalizeTerminalTitle(title.padEnd(80)) + expect(getAgentLabel(stored)).toBe('Qoder CLI') + expect(isGeminiTerminalTitle(title)).toBe(false) + expect(detectAgentStatusFromTitle(stored)).toBe(status) + expect(normalizeTerminalTitle(stored)).toBe(stored) + }) + + it('keeps upstream Gemini titles and arbitrary triangle text unchanged', () => { + expect(isGeminiTerminalTitle('✦ Working (project)')).toBe(true) + expect(getAgentLabel('deploy ▲ complete')).not.toBe('Qoder CLI') + }) + + it('tracks start, work, permission, completion and resume without becoming Claude', () => { + const state = createHookListenerState() + const session = { session_id: 'qoder-session', transcript_path: '/tmp/qoder-session.jsonl' } + const send = (hook_event_name: string, extra = {}) => + normalizeAndAccept(state, 'qoder', { ...session, hook_event_name, ...extra }) + const started = send('SessionStart', { source: 'startup' }) + expect(started?.payload).toMatchObject({ + agentType: 'qoder', + state: 'done', + sessionBoundary: true + }) + expect(started?.providerSession).toMatchObject({ id: 'qoder-session' }) + expect(send('UserPromptSubmit', { prompt: 'Fix the bug' })?.payload).toMatchObject({ + state: 'working', + agentType: 'qoder', + prompt: 'Fix the bug' + }) + expect( + send('PermissionRequest', { tool_name: 'Bash', tool_input: { command: 'pwd' } })?.payload + ).toMatchObject({ state: 'waiting', toolName: 'Bash' }) + expect(send('PostToolUse', { tool_name: 'Bash' })?.payload.state).toBe('working') + expect(send('SessionStart', { source: 'compact' })).toBeNull() + expect(send('Stop', { last_assistant_message: 'Fixed.' })?.payload).toMatchObject({ + state: 'done', + lastAssistantMessage: 'Fixed.' + }) + expect(send('StopFailure', { error: 'Unavailable' })?.payload.mainAgent).toMatchObject({ + outcome: 'failure' + }) + expect(getAgentResumeArgv('qoder', { key: 'session_id', id: 'qoder-session' })).toEqual([ + 'qodercli', + '--resume', + 'qoder-session' + ]) + }) +}) + +it('replays captured unauthenticated startup, prompt, idle and resume hooks', () => { + const state = createHookListenerState() + const lines = readFileSync( + join(__dirname, '__fixtures__', 'qoder-no-account-hooks.jsonl'), + 'utf8' + ) + .trim() + .split('\n') + const events = lines.map((line) => normalizeAndAccept(state, 'qoder', JSON.parse(line))) + expect(events.map((event) => event?.payload.state)).toEqual([ + 'done', + 'working', + 'done', + 'done', + 'done', + 'done' + ]) + expect(events.every((event) => event?.payload.agentType === 'qoder')).toBe(true) + expect(events[4]?.payload.sessionBoundary).toBe(true) + expect(events[4]?.providerSession?.id).toBe(events[0]?.providerSession?.id) +}) + +it.each(['darwin', 'linux', 'win32'] as const)( + 'launches a Qoder prompt safely on %s', + (platform) => { + const plan = buildAgentStartupPlan({ + agent: 'qoder', + prompt: "fix Bob's branch; echo $HOME", + cmdOverrides: {}, + platform + }) + expect(plan?.expectedProcess).toBe('qodercli') + expect(plan?.followupPrompt).toBeNull() + expect(plan?.launchCommand).toBe( + platform === 'win32' + ? "qodercli --prompt-interactive 'fix Bob''s branch; echo $HOME'" + : `qodercli --prompt-interactive 'fix Bob'"'"'s branch; echo $HOME'` + ) + } +) + +it('does not rewrite DSH titles containing a pipe as Qoder', () => { + const title = '✦ 🐋 refactor parser | run tests' + expect(normalizeTerminalTitle(title)).toBe(title) + expect(getAgentLabel(title)).not.toBe('Qoder CLI') + expect(detectAgentStatusFromTitle(title)).not.toBe('working') +}) + +it('settles manual compaction without interrupting automatic compaction', () => { + const state = createHookListenerState() + const send = (hook_event_name: string, extra = {}) => + normalizeAndAccept(state, 'qoder', { hook_event_name, ...extra }) + expect(send('UserPromptSubmit', { prompt: 'Refactor' })?.payload.state).toBe('working') + expect(send('PreCompact', { trigger: 'manual' })).toBeNull() + expect(send('PostCompact', { trigger: 'auto' })).toBeNull() + expect(send('SessionStart', { source: 'compact' })).toBeNull() + expect(send('PostCompact', { trigger: 'manual' })?.payload).toMatchObject({ + state: 'done', + sessionBoundary: true, + agentType: 'qoder' + }) +}) diff --git a/src/shared/qoder-headless-command.ts b/src/shared/qoder-headless-command.ts new file mode 100644 index 00000000000..6ffb16ca9b3 --- /dev/null +++ b/src/shared/qoder-headless-command.ts @@ -0,0 +1,31 @@ +import { isPrintModeHeadlessOneShotCommand, optionName } from './print-mode-headless-command' + +export function isQoderHeadlessCommand(tokens: readonly string[]): boolean { + if (isPrintModeHeadlessOneShotCommand(tokens)) { + return true + } + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index] + if (token === '--') { + return false + } + const name = optionName(token) + const value = token.includes('=') ? token.slice(token.indexOf('=') + 1) : tokens[index + 1] + if (name === '-o' && (value === 'json' || value === 'stream-json')) { + return true + } + if (name === '--input-format' && value === 'stream-json') { + return true + } + if ( + name === '--remote-control' || + name === '--remote' || + name === '--list-sessions' || + name === '--delete-session' || + name === '--acp' + ) { + return true + } + } + return false +} diff --git a/src/shared/qoder-terminal-title.ts b/src/shared/qoder-terminal-title.ts new file mode 100644 index 00000000000..e21b9dbb0e3 --- /dev/null +++ b/src/shared/qoder-terminal-title.ts @@ -0,0 +1,21 @@ +import { isDshTerminalTitle } from './dsh-terminal-title' + +// Qoder 1.1.64: one space after the glyph, then a session title and a pipe-delimited status. +const QODER_DYNAMIC_TITLE_RE = /^[✦◇▲] (?! ).*\s\|\s[^|]+$/ +const QODER_LABEL_RE = /^(?:[✦◇▲] )?Qoder CLI(?: CN)?(?: \(.*\))?$/ + +export function isQoderTerminalTitle(title: string): boolean { + if (isDshTerminalTitle(title)) { + return false + } + const trimmed = title.trim() + return QODER_DYNAMIC_TITLE_RE.test(trimmed) || QODER_LABEL_RE.test(trimmed) +} + +export function qoderTitleStatus(title: string): 'working' | 'permission' | 'idle' | null { + if (!isQoderTerminalTitle(title)) { + return null + } + const glyph = title.trimStart()[0] + return glyph === '✦' ? 'working' : glyph === '▲' ? 'permission' : 'idle' +} diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index 3dade988f0b..494f56e4dff 100644 --- a/src/shared/rate-limit-state-factory.ts +++ b/src/shared/rate-limit-state-factory.ts @@ -12,6 +12,7 @@ export function createEmptyRateLimitState(overrides: Partial = { minimax: null, grok: null, cursor: null, + zcode: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index b24e4038579..556af578565 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -17,6 +17,7 @@ describe('RateLimitState', () => { minimax: null, grok: null, cursor: null, + zcode: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, opencodeGoApiKeyConfigured: false, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 48ff7c7abe3..6b06f09d10e 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -58,6 +58,7 @@ export type ProviderRateLimits = { | 'grok' | 'antigravity' | 'cursor' + | 'zcode' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -139,6 +140,7 @@ export type RateLimitState = { minimax: ProviderRateLimits | null grok: ProviderRateLimits | null cursor: ProviderRateLimits | null + zcode: ProviderRateLimits | null /** * True when a MiniMax session cookie is persisted on disk. The cookie lives * outside GlobalSettings, so this flag is the durable signal that the diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index b78a984889e..3a7f0388b38 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -70,6 +70,7 @@ export const StatusBarItem = z.enum([ 'minimax', 'grok', 'cursor', + 'zcode', 'ssh', 'resource-usage', 'ports' @@ -184,6 +185,7 @@ export const UiUpdateFields = z _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), _cursorStatusBarDefaultAdded: z.boolean().optional(), + _zcodeStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index df05a234a88..326962eae58 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -1137,6 +1137,7 @@ export const RPC_PARAMS_BY_METHOD = { 'terminal.read': TerminalRead, 'terminal.recoverPane': TerminalRecoverPane, 'terminal.rename': TerminalRename, + 'terminal.resetInputModes': TerminalHandle, 'terminal.resizeForClient': TerminalResizeForClient, 'terminal.resolveActive': TerminalResolveActive, 'terminal.resolveIdentity': TerminalHandle, diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index a85fa9d80ab..f8d1a913172 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -24,6 +24,7 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { pi: 'pi', omp: null, 'prime-agent': null, + qoder: 'qoder', gemini: 'gemini-cli', antigravity: 'antigravity', aider: null, @@ -35,6 +36,7 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { aug: 'augment', cline: 'cline', codebuff: null, + freebuff: null, 'command-code': 'command-code', continue: 'continue', cursor: 'cursor', @@ -52,7 +54,9 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { // Why: Orca detects trae by `traecli`, an alias only TRAE CN ships. trae: 'trae-cn', muse: null, - zcode: 'zcode' + zcode: 'zcode', + // Why: DSH ships skills as Cordis plugins, not a `skills --agent` target. + dsh: null } satisfies Record /** diff --git a/src/shared/source-control-ai-action-recipes.test.ts b/src/shared/source-control-ai-action-recipes.test.ts index 352f9cfa337..15e09ebe912 100644 --- a/src/shared/source-control-ai-action-recipes.test.ts +++ b/src/shared/source-control-ai-action-recipes.test.ts @@ -427,7 +427,7 @@ describe('source-control AI action recipes', () => { ).toEqual({ ok: false, error: - 'Agent "aider" does not support Source Control AI commit messages. Supported agents: OMP, Claude, Codex, OpenCode, OpenCode 2, Pi, Amp, Cursor, Kimi, Muse, GitHub Copilot, Antigravity, or Custom command.' + 'Agent "aider" does not support Source Control AI commit messages. Supported agents: OMP, Claude, Codex, OpenCode, OpenCode 2, Pi, Amp, Cursor, Kimi, Muse, DeepSeek Harness, GitHub Copilot, Antigravity, or Custom command.' }) }) }) diff --git a/src/shared/source-scan/call-argument-scan.ts b/src/shared/source-scan/call-argument-scan.ts new file mode 100644 index 00000000000..59a411aa2f0 --- /dev/null +++ b/src/shared/source-scan/call-argument-scan.ts @@ -0,0 +1,83 @@ +import { stripComments } from './source-tree-scan' + +const CLOSER_BY_OPENER: Record = { '(': ')', '[': ']', '{': '}' } + +/** Top-level argument texts of the call whose `(` ends at `start`; quote-aware, not a parser. */ +export function splitCallArguments(source: string, start: number): string[] { + const args: string[] = [] + const closers: string[] = [')'] + let current = '' + let quote: string | null = null + for (let index = start; index < source.length; index += 1) { + const char = source[index]! + if (quote) { + current += char + if (char === '\\') { + current += source[index + 1] ?? '' + index += 1 + } else if (char === quote) { + quote = null + } + continue + } + const closer = CLOSER_BY_OPENER[char] + if (char === "'" || char === '"' || char === '`') { + quote = char + } else if (closer) { + closers.push(closer) + } else if (char === closers.at(-1)) { + closers.pop() + if (closers.length === 0) { + break + } + } else if (char === ',' && closers.length === 1) { + args.push(current.trim()) + current = '' + continue + } + current += char + } + if (current.trim()) { + args.push(current.trim()) + } + return args +} + +export type RequiredCallArgument = { + /** Which positional argument must be present. */ + index: number + /** The call counts only when the text before `.method(` matches, e.g. its receiver. */ + receiver?: RegExp + /** When the argument is an object literal, whether it carries the required field. */ + acceptsObjectLiteral?: (literal: string) => boolean +} + +/** + * `line: .method(args)` for each call that leaves out a required argument. For code the compiler + * cannot check, such as `@ts-nocheck` files and `any`-typed receivers. + */ +export function findCallsMissingArgument( + source: string, + requiredByMethod: Record +): string[] { + const code = stripComments(source) + const callRe = new RegExp(`\\.\\s*(${Object.keys(requiredByMethod).join('|')})\\s*\\(`, 'g') + const missing: string[] = [] + for (const match of code.matchAll(callRe)) { + const method = match[1]! + const required = requiredByMethod[method]! + const before = code.slice(0, match.index) + if (required.receiver && !required.receiver.test(before)) { + continue + } + const args = splitCallArguments(code, match.index + match[0].length) + const argument = args[required.index] + const present = + argument !== undefined && + (!argument.startsWith('{') || (required.acceptsObjectLiteral?.(argument) ?? true)) + if (!present) { + missing.push(`${before.split('\n').length}: .${method}(${args.join(', ')})`) + } + } + return missing +} diff --git a/src/shared/ssh-pending-pty-kill.test.ts b/src/shared/ssh-pending-pty-kill.test.ts index 9b13a193a07..c2ba4cb95f1 100644 --- a/src/shared/ssh-pending-pty-kill.test.ts +++ b/src/shared/ssh-pending-pty-kill.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { decideSshPendingPtyKill, MAX_SSH_PENDING_PTY_KILLS_PER_TARGET, + normalizeSshPendingPtyKill, prunePendingSshPtyKills, SSH_PENDING_PTY_KILL_TTL_MS, type SshPendingPtyKill, @@ -64,6 +65,26 @@ describe('decideSshPendingPtyKill', () => { }) }) +// A close whose incarnation was never learned (the host was offline across a relaunch) still owes +// the kill: a `pty2:` id carries a per-start mint epoch, so the id alone names one process. +describe('a pending kill with no incarnation', () => { + const unfenced = { requestedAt: NOW, attempts: 0 } + + it('is kept for an epoch-scoped relay id and dropped for a legacy one', () => { + expect(normalizeSshPendingPtyKill(unfenced, 'pty2:epoch-a:3')).toEqual(unfenced) + expect(normalizeSshPendingPtyKill(unfenced, 'pty-3')).toBeNull() + }) + + it('replays while the host lists the id and retires once it does not', () => { + expect( + decideSshPendingPtyKill(unfenced, { hostListsPty: true, hostIncarnationId: 'inc-z' }, NOW) + ).toEqual({ action: 'replay' }) + expect( + decideSshPendingPtyKill(unfenced, { hostListsPty: false, hostIncarnationId: undefined }, NOW) + ).toEqual({ action: 'retire', reason: 'host-reports-absent' }) + }) +}) + describe('prunePendingSshPtyKills', () => { it('drops expired entries and caps the rest newest-first', () => { const entries: SshPendingPtyKillEntry[] = [ diff --git a/src/shared/ssh-pending-pty-kill.ts b/src/shared/ssh-pending-pty-kill.ts index 186ecd65acc..89472aee27f 100644 --- a/src/shared/ssh-pending-pty-kill.ts +++ b/src/shared/ssh-pending-pty-kill.ts @@ -12,21 +12,32 @@ import type { SshRemotePtyLease } from './ssh-types' * already the durable, restart-surviving, per-`(targetId, relayPtyId)` record of a remote PTY. */ export type SshPendingPtyKill = { requestedAt: number - /** The host-minted PTY incarnation this kill was aimed at, and the whole fence. + /** The host-minted PTY incarnation this kill was aimed at, and the fence. * - * A relay renumbers from `pty-1` on every start, so `(targetId, relayPtyId)` alone can name a - * DIFFERENT shell after a redeploy — the collision behind #16970. Current relays enforce this - * identity on `pty.shutdown`; the client also proves it from `pty.listProcesses` before replay so - * older relays that ignore the additive field keep the safest available fallback. */ - incarnationId: string + * A legacy relay renumbers from `pty-1` on every start, so `(targetId, relayPtyId)` alone can + * name a DIFFERENT shell after a redeploy — the collision behind #16970. Current relays enforce + * this identity on `pty.shutdown`; the client also proves it from `pty.listProcesses` before + * replay so older relays that ignore the additive field keep the safest available fallback. + * + * Absent only for a `pty2:` id, whose per-start mint epoch makes the id itself the fence. */ + incarnationId?: string /** Replays attempted since. Diagnostic; the TTL, not this, is the bound. */ attempts: number } +/** Whether a relay PTY id names exactly one process: current relays mint `pty2::` with a + * fresh epoch per start, so the id never recurs. Legacy `pty-N` ids restart at 1. */ +export function isEpochScopedRelayPtyId(relayPtyId: string): boolean { + return relayPtyId.startsWith('pty2:') +} + /** Colocated with the type so the two cannot drift. The lease loader is a strict whitelist that * drops anything it does not name, so a record omitted here would be silently stripped on every * launch — the exact failure `closed-terminal-tab-tombstones.ts` records having shipped once. */ -export function normalizeSshPendingPtyKill(value: unknown): SshPendingPtyKill | null { +export function normalizeSshPendingPtyKill( + value: unknown, + relayPtyId: string +): SshPendingPtyKill | null { if (!value || typeof value !== 'object') { return null } @@ -34,7 +45,11 @@ export function normalizeSshPendingPtyKill(value: unknown): SshPendingPtyKill | if (typeof raw.requestedAt !== 'number' || !Number.isFinite(raw.requestedAt)) { return null } - // No incarnation, no fence, and an unfenced kill order is worse than none. + const attempts = typeof raw.attempts === 'number' && raw.attempts >= 0 ? raw.attempts : 0 + if (raw.incarnationId === undefined && isEpochScopedRelayPtyId(relayPtyId)) { + return { requestedAt: raw.requestedAt, attempts } + } + // No fence, and an unfenced kill order is worse than none. if ( typeof raw.incarnationId !== 'string' || !raw.incarnationId || @@ -42,11 +57,7 @@ export function normalizeSshPendingPtyKill(value: unknown): SshPendingPtyKill | ) { return null } - return { - requestedAt: raw.requestedAt, - incarnationId: raw.incarnationId, - attempts: typeof raw.attempts === 'number' && raw.attempts >= 0 ? raw.attempts : 0 - } + return { requestedAt: raw.requestedAt, incarnationId: raw.incarnationId, attempts } } /** Backstop only — host acknowledgement is the normal exit. This covers a target the user never @@ -107,6 +118,10 @@ export function decideSshPendingPtyKill( if (!observation.hostListsPty) { return { action: 'retire', reason: 'host-reports-absent' } } + if (intent.incarnationId === undefined) { + // The id is the fence: only an epoch-scoped id is ever recorded without an incarnation. + return { action: 'replay' } + } if (observation.hostIncarnationId === undefined) { // Why not replay: an unfenced kill against a renumbered relay id destroys a shell nobody asked // to close, which is strictly worse than the leak. Hosts predating the published incarnation diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 2eb855d9dd5..c32958ee949 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -10,6 +10,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'minimax', 'grok', 'cursor', + 'zcode', 'ssh', 'resource-usage', 'ports' diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts new file mode 100644 index 00000000000..be16c8a3fbd --- /dev/null +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_FAILURE_KINDS, + agentSessionFailureFact, + isSubmissionRejectionKind +} from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_QUEUE_FULL, + isWriteFailureSubmission +} from './structured-agent-session-dispatch-rejection' + +describe('classifyDispatchRejection', () => { + it.each([ + [DISPATCH_REJECTED_CANCELLED, 'withdrawn', null, 'cancelled'], + [DISPATCH_REJECTED_HOST_RESTARTED, 'undelivered', null, 'hostRestarted'], + [DISPATCH_REJECTED_PROVIDER_CLOSED, 'undelivered', null, 'chatClosed'], + ['not_delivered', 'undelivered', null, 'notDelivered'], + [DISPATCH_REJECTED_QUEUE_FULL, 'transport', 'failure', 'queueFull'], + [DISPATCH_REJECTED_CODEX_QUEUE_FULL, 'transport', 'failure', 'queueFull'], + ['provider_write_failed: broken pipe', 'transport', 'failure', 'writeFailed'], + ['provider_write_failed', 'transport', 'failure', 'writeFailed'] + ] as const)('reads the legacy marker %j', (reason, category, verdict, kind) => { + expect(classifyDispatchRejection({ reason })).toEqual({ category, verdict, kind }) + }) + + it('reads any other legacy reason as a sentence that failed the send', () => { + expect(classifyDispatchRejection({ reason: 'Claude does not support .bmp' })).toEqual({ + category: 'content', + verdict: 'failure' + }) + expect(classifyDispatchRejection({ reason: null })).toEqual({ + category: 'content', + verdict: 'failure' + }) + }) + + it('fails the send for every kind but a withdrawal, a restart, a close, or a lost send', () => { + const noOneFailed = ['cancelled', 'hostRestarted', 'chatClosed', 'notDelivered'] + for (const kind of AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind)) { + const verdict = classifyDispatchRejection({ reason: 'x', rejection: { kind } }).verdict + expect([kind, verdict]).toEqual([kind, noOneFailed.includes(kind) ? null : 'failure']) + } + }) + + it.each([ + ['cancelled', 'withdrawn', null], + ['hostRestarted', 'undelivered', null], + ['chatClosed', 'undelivered', null], + ['notDelivered', 'undelivered', null], + // Orca stopped a start that hung: the agent failed, and the message never reached it. + ['hostStopped', 'undelivered', 'failure'], + ['writeFailed', 'transport', 'failure'], + ['queueFull', 'transport', 'failure'] + ] as const)('reads the written %s rejection as %s, verdict %s', (kind, category, verdict) => { + const written = agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'rejection', + provider: 'claude' + }) + expect(classifyDispatchRejection(written)).toEqual({ category, verdict, kind }) + }) + + it('reads a legacy not_delivered row with no fact as a send nobody failed', () => { + expect(classifyDispatchRejection({ reason: 'not_delivered' })).toEqual({ + category: 'undelivered', + verdict: null, + kind: 'notDelivered' + }) + }) + + it('reads the typed fact over the sentence beside it', () => { + expect( + classifyDispatchRejection({ + reason: 'The provider stopped before it finished starting.', + rejection: { kind: 'providerStartFailed' } + }) + ).toEqual({ category: 'startFailed', verdict: 'failure', kind: 'providerStartFailed' }) + expect( + classifyDispatchRejection({ reason: 'Not sent.', rejection: { kind: 'queueFull' } }) + ).toMatchObject({ category: 'transport', verdict: 'failure' }) + }) + + it('writes a sentence, not a marker released clients would print, for a restart or a close', () => { + for (const kind of ['hostRestarted', 'chatClosed'] as const) { + const written = agentSessionFailureWords(agentSessionFailureFact(kind), { + surface: 'rejection' + }) + expect(written.reason).not.toMatch(/^[a-z_]+$/) + expect(classifyDispatchRejection(written)).toEqual({ + category: 'undelivered', + verdict: null, + kind + }) + } + }) + + it('gives a fact it cannot place no verdict, whatever the reason beside it says', () => { + // A newer host's kind, a status-row kind, and a fact with no kind at all. + for (const rejection of [{ kind: 'futureKind' }, { kind: 'providerRetrying' }, {}]) { + for (const reason of [ + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_CANCELLED, + 'Refused.' + ]) { + expect(classifyDispatchRejection({ reason, rejection })).toEqual({ + category: 'undelivered', + verdict: null + }) + } + } + }) +}) + +describe('isWriteFailureSubmission', () => { + it('holds for a legacy row in any state that carries the marker', () => { + expect( + isWriteFailureSubmission({ reason: 'provider_write_failed: closed before enqueue' }) + ).toBe(true) + }) + + it('holds for the bare marker a new host writes, as released clients already read it', () => { + const written = agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { + surface: 'rejection' + }) + expect(written.reason).toBe('provider_write_failed') + expect(isWriteFailureSubmission({ reason: written.reason })).toBe(true) + }) + + it('holds for a typed write failure and for nothing else', () => { + expect(isWriteFailureSubmission({ reason: 'x', rejection: { kind: 'writeFailed' } })).toBe(true) + expect(isWriteFailureSubmission({ reason: DISPATCH_REJECTED_QUEUE_FULL })).toBe(false) + expect(isWriteFailureSubmission({ reason: 'x', rejection: { kind: 'queueFull' } })).toBe(false) + expect(isWriteFailureSubmission({ reason: null })).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 5c4e1d0f0a8..3b9ff4fb025 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -1,23 +1,23 @@ // Why a submission is `rejected`: the message provably did not happen. // -// Two different facts reach this state, and the reason string is what tells them -// apart. A CONTENT rejection is permanent — the provider looked at this payload -// and refused it, and it would refuse the same payload again; its reason is the -// provider's own explanation ("Claude messages support at most 20 images") and is -// meant to be read. A TRANSPORT rejection is transient — the frame was never -// handed to the provider at all, so the same text under a fresh id is a first -// delivery and the next attempt may well succeed; its reason names an internal -// cause and must NOT be put in front of a person. +// The host writes each rejection twice: `reason`, a sentence (or, for the cases older clients +// already recognise, one of the legacy markers below) that released clients print as it is, and +// `rejection`, the typed fact newer clients read — both from `agentSessionFailureWords`. +// `classifyDispatchRejection` is the only place a reader judges either. // -// Both are `rejected` because both make the single claim that state exists to -// make: this message did not reach the provider. Neither is ever re-delivered -// under its own id — `rejected` is terminal in the reducer — so a retry rotates -// the client message id, which is a new message and cannot duplicate. -// -// Shared rather than main-only because both sides need it: the host writes the -// reason, and a client has to know which kind it is holding before it can decide -// whether the string is showable. +// Every rejection makes the one claim that state exists to make: this message did not reach the +// provider. None is ever re-delivered under its own id — `rejected` is terminal in the reducer — so +// a retry rotates the client message id, which is a new message and cannot duplicate. +import { + isSubmissionRejectionKind, + readAgentSessionFailureFact, + type SubmissionRejectionKind +} from './agent-session-failure' +import type { AgentJournalSubmission } from './agent-session-journal-types' + +/** Orca could not hand the message over. Written bare: released clients hide it, and the error + * that once followed it belongs in the log. Rows from older hosts carry `: ` after it. */ export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' /** Local admission refused the frame before any transport was involved. Two @@ -29,24 +29,21 @@ export const DISPATCH_REJECTED_CODEX_QUEUE_FULL = 'codex structured dispatch que /** The provider confirmed a queued frame was withdrawn before execution. */ export const DISPATCH_REJECTED_CANCELLED = 'provider_cancelled_before_start' -/** Accepted by a host process that ended before handing it to any provider. */ +/** Legacy marker: accepted by a host process that ended before handing it to any provider. + * Read only; released clients print it raw, so new rows carry a sentence. */ export const DISPATCH_REJECTED_HOST_RESTARTED = 'host_restarted_before_delivery' -/** Accepted, then the provider was closed before the message was handed to it. */ +/** Legacy marker: accepted, then the provider was closed before the message was handed to it. + * Read only, like the one above. */ export const DISPATCH_REJECTED_PROVIDER_CLOSED = 'provider_closed_before_delivery' -/** Crash recovery found the send absent from a provider history it could trust. */ +/** Restart reconciliation found the send absent from a provider history it could trust. Rows + * written before rows carried a fact hold it as their reason; released clients printed it. */ export const DISPATCH_REJECTED_NOT_DELIVERED = 'not_delivered' -export function dispatchWriteFailureReason(error: unknown): string { - const detail = error instanceof Error ? error.message : String(error) - return `${DISPATCH_REJECTED_WRITE_FAILED}: ${detail}` -} - -/** True for the internal transport marker, false for a provider's own words. */ -export function dispatchRejectionWasTransportWriteFailure( - reason: string | null | undefined -): boolean { +/** True for the internal transport marker, false for a provider's own words. Legacy-reason half + * of `isWriteFailureSubmission`; readers go through that or the classifier. */ +function dispatchRejectionWasTransportWriteFailure(reason: string | null | undefined): boolean { return ( reason === DISPATCH_REJECTED_WRITE_FAILED || reason?.startsWith(`${DISPATCH_REJECTED_WRITE_FAILED}: `) === true @@ -54,33 +51,128 @@ export function dispatchRejectionWasTransportWriteFailure( } /** - * True when the reason is ours rather than the provider's, so it must not be - * shown verbatim. A content rejection carries the provider's own explanation and - * is the only kind a person should read. + * What a rejection means for the chat, whatever wrote it: + * - `withdrawn`: the user's Stop took it back; nothing failed. + * - `undelivered`: accepted, then never handed over (Orca restarted, the chat closed, the provider + * never took it or stopped first). + * - `startFailed`: the agent it waited on did not start. + * - `content`: the provider, or Orca's check of the message, refused this payload. + * - `transport`: Orca could not hand it over. */ -export function dispatchRejectionReasonIsInternal(reason: string | null | undefined): boolean { - return ( - dispatchRejectionWasTransportWriteFailure(reason) || - reason === DISPATCH_REJECTED_QUEUE_FULL || - reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL || - reason === DISPATCH_REJECTED_CANCELLED || - reason === DISPATCH_REJECTED_HOST_RESTARTED || - reason === DISPATCH_REJECTED_PROVIDER_CLOSED || - reason === DISPATCH_REJECTED_NOT_DELIVERED - ) +export type DispatchRejectionCategory = + | 'withdrawn' + | 'undelivered' + | 'startFailed' + | 'content' + | 'transport' + +export type DispatchRejectionClassification = { + category: DispatchRejectionCategory + /** `failure` when the chat reads Failed; null only when no one failed the user: a withdrawal, a + * host restart, a chat close, or a send the provider never received after a restart. */ + verdict: 'failure' | null + /** The situation, when the row carried one or a legacy marker names it; absent for a legacy + * sentence, whose words are all a reader has, and for a fact this build cannot place. */ + kind?: SubmissionRejectionKind +} + +const KIND_CATEGORY = { + cancelled: 'withdrawn', + hostRestarted: 'undelivered', + chatClosed: 'undelivered', + notDelivered: 'undelivered', + providerExited: 'undelivered', + hostStopped: 'undelivered', + providerStartFailed: 'startFailed', + startFailed: 'startFailed', + notSignedIn: 'startFailed', + historyTooLarge: 'startFailed', + managedAccountEnvOverride: 'startFailed', + accountSwitchInProgress: 'startFailed', + managedAccountUnsupported: 'startFailed', + restartFailed: 'startFailed', + providerRejected: 'content', + attachmentInvalid: 'content', + attachmentUnreadable: 'content', + emptyMessage: 'content', + queueFull: 'transport', + writeFailed: 'transport', + hostFault: 'transport' +} satisfies Record + +/** Null only where no one failed the user; a new kind does not compile until it is placed here. */ +const KIND_VERDICT = { + cancelled: null, + hostRestarted: null, + chatClosed: null, + notDelivered: null, + providerExited: 'failure', + providerStartFailed: 'failure', + startFailed: 'failure', + notSignedIn: 'failure', + historyTooLarge: 'failure', + managedAccountEnvOverride: 'failure', + accountSwitchInProgress: 'failure', + managedAccountUnsupported: 'failure', + restartFailed: 'failure', + providerRejected: 'failure', + attachmentInvalid: 'failure', + attachmentUnreadable: 'failure', + emptyMessage: 'failure', + queueFull: 'failure', + writeFailed: 'failure', + hostFault: 'failure', + // Orca stopped a start that hung: the agent failed. + hostStopped: 'failure' +} satisfies Record + +/** The legacy markers, by the kind each stands for. */ +const LEGACY_MARKER_KINDS: ReadonlyMap = new Map([ + [DISPATCH_REJECTED_CANCELLED, 'cancelled'], + [DISPATCH_REJECTED_HOST_RESTARTED, 'hostRestarted'], + [DISPATCH_REJECTED_PROVIDER_CLOSED, 'chatClosed'], + [DISPATCH_REJECTED_QUEUE_FULL, 'queueFull'], + [DISPATCH_REJECTED_CODEX_QUEUE_FULL, 'queueFull'], + [DISPATCH_REJECTED_NOT_DELIVERED, 'notDelivered'] +]) + +/** The kind a legacy marker stands for; undefined for any other reason, which is a sentence. */ +function legacyMarkerKind(reason: string | null): SubmissionRejectionKind | undefined { + if (dispatchRejectionWasTransportWriteFailure(reason)) { + return 'writeFailed' + } + return reason === null ? undefined : LEGACY_MARKER_KINDS.get(reason) } /** - * What a rejected send says about the request, for the session's verdict: `failure` when the - * agent or its start refused it, null when nobody failed. A withdrawn send, and one a restart, - * a close or a crash left undelivered, are not verdicts — the chat shows them unsent with Retry. - * An allowlist of the null reasons, because every other reason includes open provider text. + * The one reader of why a submission was rejected. The typed fact decides when the row carries + * one; a fact this build cannot place says only that the message was not sent. A row with no + * fact, from an older host, is read by its legacy marker, and any other reason is a sentence — a + * provider's, or Orca's before rows were typed — which reads as a content failure. */ -export function dispatchRejectionVerdict(reason: string | null | undefined): 'failure' | null { - return reason === DISPATCH_REJECTED_CANCELLED || - reason === DISPATCH_REJECTED_HOST_RESTARTED || - reason === DISPATCH_REJECTED_PROVIDER_CLOSED || - reason === DISPATCH_REJECTED_NOT_DELIVERED - ? null - : 'failure' +export function classifyDispatchRejection( + submission: Pick & { rejection?: unknown } +): DispatchRejectionClassification { + const { rejection } = submission + const factKind = readAgentSessionFailureFact(rejection)?.kind + if (typeof rejection === 'object' && rejection !== null && !isSubmissionRejectionKind(factKind)) { + // Undelivered, not content: all a fact this build can't place proves is the message didn't happen. + return { category: 'undelivered', verdict: null } + } + const kind = isSubmissionRejectionKind(factKind) ? factKind : legacyMarkerKind(submission.reason) + if (!kind) { + return { category: 'content', verdict: 'failure' } + } + return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } +} + +/** A submission that says Orca never handed it over, in any dispatch state: journals written + * before this state moved hold it as `unknown` carrying the marker. */ +export function isWriteFailureSubmission( + submission: Pick +): boolean { + return ( + readAgentSessionFailureFact(submission.rejection)?.kind === 'writeFailed' || + dispatchRejectionWasTransportWriteFailure(submission.reason) + ) } diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 780a636064c..7cdb1b2d36a 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -4,14 +4,22 @@ import type { AgentJournalSubmission, AgentJournalTurnLifecycle } from './agent-session-journal-types' +import { AGENT_SESSION_FAILURE_KINDS, isSubmissionRejectionKind } from './agent-session-failure' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { + classifyDispatchRejection, DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, DISPATCH_REJECTED_HOST_RESTARTED, DISPATCH_REJECTED_NOT_DELIVERED, - DISPATCH_REJECTED_PROVIDER_CLOSED + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_WRITE_FAILED } from './structured-agent-session-dispatch-rejection' -import { latestStructuredAgentSessionRequest } from './structured-agent-session-latest-request' +import { + hasStructuredAgentSessionRequest, + latestStructuredAgentSessionRequest +} from './structured-agent-session-latest-request' import { projectStructuredAgentSessionStatusSummary } from './structured-agent-session-projection' const START_FAILURE = 'Claude is not signed in.' @@ -245,3 +253,61 @@ describe('the latest request and its verdict', () => { }) }) }) + +describe('the sidebar verdict agrees with the rejection classifier', () => { + // A sentence as the reason, so only the typed fact can make a kind read as no verdict. + const typed = AGENT_SESSION_FAILURE_KINDS.filter(isSubmissionRejectionKind).map((kind) => ({ + name: `typed ${kind}`, + submission: sent('m1', { + dispatchState: 'rejected', + reason: 'The message was not sent.', + rejection: { kind } + }) + })) + const legacy = [ + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_QUEUE_FULL, + DISPATCH_REJECTED_CODEX_QUEUE_FULL, + DISPATCH_REJECTED_HOST_RESTARTED, + DISPATCH_REJECTED_PROVIDER_CLOSED, + DISPATCH_REJECTED_NOT_DELIVERED, + DISPATCH_REJECTED_WRITE_FAILED, + `${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe` + ].map((reason) => ({ name: `legacy ${reason}`, submission: rejected('m1', reason) })) + const unknownKind = { + name: 'a kind this build cannot place', + submission: sent('m1', { + dispatchState: 'rejected', + reason: 'A newer host wrote this.', + rejection: { kind: 'someFutureKind' } + }) + } + + it.each([...typed, ...legacy, unknownKind])('$name', ({ submission }) => { + const items = [userEntry('m1', 1)] + const { verdict } = classifyDispatchRejection(submission) + expect(latestStructuredAgentSessionRequest(items, [submission])?.outcome ?? null).toBe(verdict) + expect(hasStructuredAgentSessionRequest(items, [submission])).toBe(verdict === 'failure') + }) + + const failedNobody = new Set([ + 'typed notDelivered', + 'typed cancelled', + 'typed hostRestarted', + 'typed chatClosed', + `legacy ${DISPATCH_REJECTED_NOT_DELIVERED}`, + `legacy ${DISPATCH_REJECTED_CANCELLED}`, + `legacy ${DISPATCH_REJECTED_HOST_RESTARTED}`, + `legacy ${DISPATCH_REJECTED_PROVIDER_CLOSED}` + ]) + const failedNobodyCases = [...typed, ...legacy].filter(({ name }) => failedNobody.has(name)) + it('covers every send that failed nobody', () => { + expect(failedNobodyCases).toHaveLength(failedNobody.size) + }) + it.each(failedNobodyCases)( + 'gives no verdict for a send that failed nobody: $name', + ({ submission }) => { + expect(latestStructuredAgentSessionRequest([userEntry('m1', 1)], [submission])).toBeNull() + } + ) +}) diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 9fae8a39960..9679c0c12bd 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -14,7 +14,7 @@ import type { import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' -import { dispatchRejectionVerdict } from './structured-agent-session-dispatch-rejection' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' export type StructuredAgentSessionLatestRequest = { @@ -55,7 +55,7 @@ export function latestStructuredAgentSessionRequest( const submission = rejected.get(item.itemId) if ( submission && - dispatchRejectionVerdict(submission.reason) === 'failure' && + classifyDispatchRejection(submission).verdict === 'failure' && !deliveredIntoRunningTurn(items, index, submission) ) { return { @@ -94,7 +94,7 @@ export function hasStructuredAgentSessionRequest( submission.dispatchState === 'accepted' || isUnansweredStructuredAgentSessionDispatch(submission, currentFence) || (submission.dispatchState === 'rejected' && - dispatchRejectionVerdict(submission.reason) === 'failure') + classifyDispatchRejection(submission).verdict === 'failure') ) ) } diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 6219538fbec..334cde7cfe8 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -1,5 +1,6 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { agentJournalItemPosition } from './agent-session-journal-position' import type { NativeChatMessage } from './native-chat-types' import { reconcileStructuredAgentSessionOutbox, @@ -20,18 +21,32 @@ export function projectStructuredAgentSessionMessages( .filter((submission) => submission.dispatchState === 'rejected') .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) - const visibleItems = items.filter((item) => !rejected.has(item.itemId)) + const visibleItems: AgentJournalRenderItem[] = [] + const refused = new Map() + for (const item of items) { + if (rejected.has(item.itemId)) { + refused.set(item.itemId, item) + } else { + visibleItems.push(item) + } + } const journalled = new Set(visibleItems.map((item) => item.itemId)) return [ ...projectItems(visibleItems), ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) - .map((entry): NativeChatMessage => ({ - id: agentJournalSubmissionKey(entry.clientMessageId), - role: 'user', - source: 'transcript', - timestamp: entry.queuedAt, - blocks: entry.body.blocks - })) + .map((entry): NativeChatMessage => { + const id = agentJournalSubmissionKey(entry.clientMessageId) + const recorded = refused.get(id) + return { + id, + role: 'user', + source: 'transcript', + timestamp: entry.queuedAt, + blocks: entry.body.blocks, + // A send the journal recorded before refusing it keeps its place there. + ...(recorded ? { journalPosition: agentJournalItemPosition(recorded) } : {}) + } + }) ] } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 6c820921162..c14209a716c 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -1,15 +1,17 @@ +import { readAgentSessionFailureFact, type AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types' import { parseAgentSessionWriteFailure, - type AgentSessionWriteFailure -} from './agent-session-refusal-notice' -import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' -import type { - AgentSessionMutationEnvelope, - AgentSessionWireRefusalCode -} from './agent-session-wire' + type AgentSessionWriteFailure, + type AgentSessionWriteRefusal +} from './agent-session-write-failure' +import { + agentSessionOwnerVerdictAllowsFreshOperationId, + agentSessionRefusalOperationState +} from './agent-session-refusal-retry' +import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' -import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' /** `rejected`: the host settled the send as not delivered. The drain never sends it again on its * own and nothing queues behind it; only the user's Retry does. */ @@ -34,11 +36,40 @@ export type StructuredAgentSessionOutboxEntry = { lastFailure?: StructuredAgentSessionAttemptFailure } +/** A host's rejection fact as a message keeps it: never its provider detail, whose log text is not + * kept client-side, or its refusal. The journal row keeps the whole fact, and words the notice + * while it is loaded; this copy words it when it is not. */ +export type StructuredAgentSessionRejectionFact = Pick< + AgentSessionFailureFact, + 'kind' | 'attachment' +> + /** Kept as the fact, not the words: the Retry row chooses those when it shows the entry. */ export type StructuredAgentSessionAttemptFailure = | AgentSessionWriteFailure /** The host recorded the message and the provider turned it down, with the provider's reason. */ - | { kind: 'rejected'; reason: string | null } + | { kind: 'rejected'; reason: string | null; rejection?: StructuredAgentSessionRejectionFact } + +/** The failure a rejected submission leaves on its message. A fact this build cannot place is + * dropped, leaving the reason. */ +export function structuredAgentSessionRejectedFailure(submission: { + reason: string | null + rejection?: unknown +}): Extract { + const fact = readAgentSessionFailureFact(submission.rejection) + return { + kind: 'rejected', + reason: submission.reason, + ...(fact + ? { + rejection: { + kind: fact.kind, + ...(fact.attachment ? { attachment: fact.attachment } : {}) + } + } + : {}) + } +} function parseStructuredAgentSessionAttemptFailure( value: unknown @@ -51,7 +82,10 @@ function parseStructuredAgentSessionAttemptFailure( 'reason' in value ) { return value.reason === null || typeof value.reason === 'string' - ? { kind: 'rejected', reason: value.reason } + ? structuredAgentSessionRejectedFailure({ + reason: value.reason, + rejection: 'rejection' in value ? value.rejection : undefined + }) : undefined } return parseAgentSessionWriteFailure(value) @@ -119,25 +153,31 @@ export function stageStructuredAgentSessionOutboxEntryForSend( export function requeueStructuredAgentSessionSendRefusal( entry: StructuredAgentSessionOutboxEntry, - code: AgentSessionWireRefusalCode, + refusal: AgentSessionWriteRefusal, createOperationId: () => string, retainOperationId = false ): StructuredAgentSessionOutboxEntry { - const refusalState = agentSessionRefusalOperationState(code) + const refusalSettled = agentSessionRefusalOperationState(refusal.code) === 'settled-rejected' + // An exited owner runs nothing under the old id, so a new one can't collide; the message still + // holds the head, since nothing recorded it. + const ownerExited = + refusal.code === 'agent_session_ownership_unknown' && + agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict) if ( - refusalState !== 'settled-rejected' || + !(refusalSettled || ownerExited) || retainOperationId || entry.state === 'unconfirmed' || entry.retryAfterUnknownSubmittedAt !== null ) { return { ...entry, state: 'queued' } } - // Only here is the refusal proof the message never landed: an earlier attempt under this id, or - // one whose delivery was in doubt, may have, so those stay queued behind the block. + // Only here may the id rotate: an earlier attempt under this id, or one whose delivery was in + // doubt, may have landed, so those stay queued behind the block. Only a settled refusal proves + // the message never landed and so releases the queue. return { ...entry, clientMessageId: createOperationId(), - state: 'rejected', + state: refusalSettled ? 'rejected' : 'queued', lastAttemptAt: null, retryAfterUnknownSubmittedAt: null } @@ -155,7 +195,7 @@ export function reconcileStructuredAgentSessionOutbox( } if ( submission?.dispatchState === 'rejected' && - submission.reason === DISPATCH_REJECTED_CANCELLED + classifyDispatchRejection(submission).category === 'withdrawn' ) { return [] } @@ -173,7 +213,7 @@ export function reconcileStructuredAgentSessionOutbox( { ...entry, state: 'rejected' as const, - lastFailure: { kind: 'rejected' as const, reason: submission.reason } + lastFailure: structuredAgentSessionRejectedFailure(submission) } ] } diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 6dbe9f90787..f26f409afd4 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' +import { agentSessionFailureWords } from './agent-session-failure-words' import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { parsePaneKey } from './stable-pane-id' import { @@ -103,6 +104,26 @@ describe('structured agent session status projection', () => { }) }) + it("forwards a status row's failure fact and drops one this build cannot place", () => { + const words = agentSessionFailureWords( + { + kind: 'providerExited', + detail: { text: 'stderr tail', audience: 'log' } + }, + { surface: 'row' } + ) + expect( + projectStructuredItemToNativeChat(item('exit', 1, { kind: 'status', ...words }))?.blocks[0] + ).toEqual({ type: 'text', ...words }) + const future = item('future', 2, { kind: 'status', text: 'Stopped.' }) + // A newer host's kind reads as no fact, so the row keeps its text and nothing else. + Object.assign(future.body, { failure: { kind: 'futureKind' } }) + expect(projectStructuredItemToNativeChat(future)?.blocks[0]).toEqual({ + type: 'text', + text: 'Stopped.' + }) + }) + it('projects running, attention, and completed lifecycle states', () => { const running = item('running', 1, { kind: 'status', diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 023c9ef0970..f444af0a060 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -11,6 +11,8 @@ import { type AgentJournalTurnOutcome } from './agent-session-journal-types' import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block' +import { agentJournalItemRowOrigin } from './agent-session-journal-position' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH @@ -121,18 +123,7 @@ function itemBlocks(item: AgentJournalRenderItem): { if (body.kind !== 'status' || body.turnLifecycle) { return null } - return { - role: 'system', - blocks: [ - { - type: 'text', - text: body.text, - ...(body.presentation !== undefined ? { presentation: body.presentation } : {}), - ...(body.tone !== undefined ? { tone: body.tone } : {}), - ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}) - } - ] - } + return { role: 'system', blocks: [structuredAgentSessionStatusBlock(body)] } } function isAgentJournalMessageSendMode(value: string): value is AgentJournalMessageSendMode { @@ -170,11 +161,9 @@ export function projectStructuredItemToNativeChat( const sentAs = item.body.kind === 'message' ? item.body.sentAs : undefined const message: NativeChatMessage | null = projected ? { - id: item.itemId, + ...agentJournalItemRowOrigin(item), role: projected.role, blocks: projected.blocks, - timestamp: item.observedAt, - source: 'transcript', // A send mode this build cannot name renders as an ordinary message. ...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}) } diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts index 18487f12be3..74279d7811d 100644 --- a/src/shared/structured-agent-session-read-refusal.ts +++ b/src/shared/structured-agent-session-read-refusal.ts @@ -1,11 +1,13 @@ +import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' + /** * The one refusal a structured-session READ can raise that is not a failure to read. * - * `agentSession.history` and `agentSession.subscribe` both resolve the session through the host's - * `requireSession`, which raises this code when the host holds no session object by that id. That - * is never a transcript Orca could not read — it is a session this host has not attached YET (the - * surface's hold is what attaches one) or one it has just closed. Both windows end on their own: - * the first when the hold lands, the second when the chat tab retires. + * A current host opens a conversation for any read, so a read meets this code only once quit began. + * An older host resolves `agentSession.history` and `agentSession.subscribe` against the sessions it + * has attached, and raises it when it holds none by that id. Either way it is never a transcript + * Orca could not read: an older host's session is not attached YET (the surface's hold attaches + * one) or was just closed, and both windows end on their own. * * The genuinely latched lease — "Orca cannot prove the previous owner exited" — reaches the client * through the ACQUISITION path instead, so narrowing on the code costs a read no real diagnosis. @@ -43,3 +45,17 @@ export function isUnattachedAgentSessionReadRefusal(error: unknown): boolean { message === AGENT_SESSION_UNATTACHED_REFUSAL_CODE ) } + +/** + * A read refusal no retry reads past: SQLite reported the chat's journal damaged. Decided from the + * reason, never the message, which is the bare code for every journal refusal; a journal that + * failed to open for any other reason can clear, so its read keeps reconnecting. + */ +export function isFinalAgentSessionReadRefusal( + refusal: AgentSessionRefusalReference | undefined +): boolean { + return ( + refusal?.code === 'agent_session_journal_unreadable' && + refusal.details?.reason === 'journalCorrupt' + ) +} diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index aa90749ffdf..ca108710bcf 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -54,6 +54,37 @@ function hydrationPage( } describe('structured agent session reducer', () => { + it("orders one journal write's items by their place in it, whatever order they arrive in", () => { + const at = (id: string, sequence: number, sequenceIndex: number): AgentJournalRenderItem => ({ + ...item(id, sequence), + ...(sequenceIndex > 0 ? { sequenceIndex } : {}) + }) + const paged = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'history-page', + page: hydrationPage([ + item('before', 4), + at('third', 5, 2), + at('first', 5, 0), + at('second', 5, 1) + ]) + }) + expect(paged.items.map(({ itemId }) => itemId)).toEqual(['before', 'first', 'second', 'third']) + const live = reduceStructuredAgentSession(paged, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 6 }, + items: [at('next-b', 6, 1), at('next-a', 6, 0)], + removedItemIds: [], + submissions: [] + } + } + }) + expect(live.items.map(({ itemId }) => itemId).slice(-2)).toEqual(['next-a', 'next-b']) + }) + it('applies an additive targeted-stop capability update without journal churn', () => { const backgroundTasks = { state: 'monitoring' as const, @@ -127,6 +158,46 @@ describe('structured agent session reducer', () => { ]) }) + it("keeps a failed read's refusal beside its text until the read recovers", () => { + const refusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } as const + const failed = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'error', + message: 'agent_session_journal_unreadable', + refusal + }) + expect(failed).toMatchObject({ status: 'error', readRefusal: refusal }) + + expect(reduceStructuredAgentSession(failed, { type: 'loading' }).readRefusal).toBeUndefined() + const live = reduceStructuredAgentSession( + reduceStructuredAgentSession(failed, { + type: 'history-page', + page: hydrationPage([item('a', 1)]) + }), + { type: 'error', message: 'transport died' } + ) + expect(live.readRefusal).toBeUndefined() + const recovered = reduceStructuredAgentSession( + { ...live, readRefusal: refusal }, + { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 2 }, + items: [item('b', 2)], + removedItemIds: [], + submissions: [] + } + } + } + ) + expect(recovered).toMatchObject({ status: 'ready', error: undefined, readRefusal: undefined }) + }) + it('uses the bounded hydration page pagination boundary', () => { const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { type: 'event', diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 2f839f93330..02c554628fb 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -10,8 +10,10 @@ import type { AgentSessionSubscribeEvent, AgentSessionTurnActivity } from './agent-session-wire' +import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { compareAgentJournalItems } from './agent-session-journal-position' import { readAgentJournalTurn } from './agent-session-turn-record' /** The last host clock sample: `hostNow - receivedAt` is the client's skew from the host, @@ -31,7 +33,10 @@ export type StructuredAgentSessionState = { retainedItemLimit: number hasOlder: boolean status: 'idle' | 'loading' | 'ready' | 'error' + /** The failed read's own text, for logs; a surface words `readRefusal` instead. */ error?: string + /** The refusal the failed read met, when the host sent one; cleared with `error`. */ + readRefusal?: AgentSessionRefusalReference backgroundTasks?: AgentSessionBackgroundTaskState | null commands?: AgentSessionSlashCommand[] | null activity?: AgentSessionTurnActivity | null @@ -44,7 +49,7 @@ export type StructuredAgentSessionState = { export type StructuredAgentSessionAction = | { type: 'loading' } - | { type: 'error'; message: string } + | { type: 'error'; message: string; refusal?: AgentSessionRefusalReference } | { type: 'event'; event: AgentSessionSubscribeEvent } | { type: 'history-page'; page: AgentSessionHistoryPage } | { type: 'older-page'; requestedCursor: AgentJournalCursor; page: AgentSessionHistoryPage } @@ -85,7 +90,7 @@ function replacePage( epoch: page.epoch, cursor: page.liveCursor ?? page.window.nextCursor, fence, - items: [...page.items].sort((left, right) => left.sequence - right.sequence), + items: [...page.items].sort(compareAgentJournalItems), submissions: page.submissions, retainedItemLimit: Math.max(MAX_RETAINED_ITEMS, page.items.length), hasOlder: page.hasOlder, @@ -114,7 +119,7 @@ function mergeItems( byId.set(item.itemId, item) } } - return [...byId.values()].sort((left, right) => left.sequence - right.sequence) + return [...byId.values()].sort(compareAgentJournalItems) } /** @@ -175,10 +180,10 @@ export function reduceStructuredAgentSession( ): StructuredAgentSessionState { if (action.type === 'loading') { // Keep the last transcript visible while a reconnect rehydrates the stream. - return { ...state, status: 'loading', error: undefined } + return { ...state, status: 'loading', error: undefined, readRefusal: undefined } } if (action.type === 'error') { - return { ...state, status: 'error', error: action.message } + return { ...state, status: 'error', error: action.message, readRefusal: action.refusal } } if (action.type === 'history-page') { return { @@ -271,6 +276,7 @@ export function reduceStructuredAgentSession( : mergeSubmissions(state.submissions, event.batch.submissions, items), status: 'ready', error: undefined, + readRefusal: undefined, commands: event.commands !== undefined ? event.commands : state.commands, ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), ...(activity !== undefined ? { activity } : {}), diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 909da35c1f3..e95a21937d1 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -4,10 +4,10 @@ // which is the whole reason it was split out. import { describe, expect, it } from 'vitest' +import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { - dispatchWriteFailureReason, DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_QUEUE_FULL } from './structured-agent-session-dispatch-rejection' @@ -31,7 +31,10 @@ const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOut queuedAt: 1 }) -function rejectedWith(reason: string | null): AgentSessionMutationResult { +function rejectedWith( + reason: string | null, + extra: { rejection?: AgentSessionFailureFact; replayed?: boolean } = {} +): AgentSessionMutationResult { const submission: AgentJournalSubmission = { clientMessageId: 'client-1', fence: 1, @@ -40,23 +43,24 @@ function rejectedWith(reason: string | null): AgentSessionMutationResult + } } -function notice(reason: string | null): string | undefined { +function notice(reason: string | null, rejection?: AgentSessionFailureFact): string | undefined { const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, blockedClientMessageId: null, - result: rejectedWith(reason), + result: rejectedWith(reason, rejection ? { rejection } : {}), createOperationId: () => 'unused' }) // The reason travels with the message it explains, never as a separate error. @@ -78,7 +82,7 @@ describe('what a rejection shows the user', () => { }) it('never puts the transport marker on screen', () => { - const shown = notice(dispatchWriteFailureReason(new Error('broken pipe'))) + const shown = notice('provider_write_failed: broken pipe') // `provider_write_failed: broken pipe` names nothing a person can act on. expect(shown).not.toContain('provider_write_failed') expect(shown).not.toContain('broken pipe') @@ -92,13 +96,53 @@ describe('what a rejection shows the user', () => { ) }) - it('names the cause of a start that died before it could take the message', () => { - // The host words this reason for the user: the child's own diagnostic, nothing internal. - const reason = - 'The provider stopped before it finished starting: claude stream-json exited (code 1): claude: not signed in.' + it('shows the sentence a host wrote for the person reading it', () => { + const reason = 'The provider stopped before it finished starting.' expect(notice(reason)).toBe(reason) }) + it('never puts the legacy not_delivered marker on screen', () => { + // Released clients printed it as it was; it is a marker, not a sentence. + expect(notice('not_delivered')).toBe('Your message was not sent.') + }) + + it.each([ + ['the legacy marker alone', undefined], + ['the marker and its typed fact', { kind: 'cancelled' as const }] + ])('fails nothing for a Stop-withdrawn send, first reply or replay: %s', (_label, rejection) => { + for (const replayed of [false, true]) { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: rejectedWith(DISPATCH_REJECTED_CANCELLED, { rejection, replayed }), + createOperationId: () => 'unused' + }) + expect(disposition).toEqual({ + entries: [], + error: null, + blockedClientMessageId: null + }) + } + }) + + it('reads a withdrawal off the typed fact whatever the reason says', () => { + const result = rejectedWith('Withdrawn.', { rejection: { kind: 'cancelled' } }) + if (!result.ok) { + throw new Error('expected rejected submission fixture') + } + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + expect( + disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result, + createOperationId: () => 'unused' + }).error + ).toBeNull() + }) + it('claims no cause when the rejection names none', () => { expect(notice(null)).toBe('Your message was not sent.') }) @@ -112,6 +156,64 @@ describe('what a rejection shows the user', () => { }) }) +// A row that carries the host's fact is worded from it; the reason is not read. +describe('what a rejection with a typed fact shows the user', () => { + it('says Orca could not hand the message over, whatever the reason holds', () => { + expect(notice('provider_write_failed', { kind: 'writeFailed' })).toBe( + "Orca couldn't reach the agent. Your message was not sent." + ) + expect(notice('Something unrelated.', { kind: 'writeFailed' })).toBe( + "Orca couldn't reach the agent. Your message was not sent." + ) + }) + + it("rebuilds the fact's sentence where a marker stands in for it", () => { + expect(notice(DISPATCH_REJECTED_QUEUE_FULL, { kind: 'queueFull' })).toBe( + 'Too many messages were waiting for the agent, so this one was not sent.' + ) + }) + + // The surface names the agent; the host's own sentence is never compared or shown. + it('words the fact itself, never the sentence the host wrote beside it', () => { + expect( + notice('Claude never finished starting, so Orca stopped it.', { kind: 'hostStopped' }) + ).toBe('The agent never finished starting, so Orca stopped it.') + }) + + // The message keeps no fact it cannot place, so the host's sentence stands, as on an older host. + it("shows a newer host's sentence when its fact cannot be placed", () => { + expect(notice('A sentence a newer host wrote.', JSON.parse('{"kind":"fromTheFuture"}'))).toBe( + 'A sentence a newer host wrote.' + ) + }) + + // The message's copy drops the detail and the refusal these kinds are worded from, so the + // sentence the host wrote for the person stands in for them; with none, the table's words. + it("shows the host's sentence for a kind whose words its copy cannot rebuild", () => { + expect( + notice('The provider did not accept this message: Image type .bmp.', { + kind: 'providerRejected', + detail: { text: 'Image type .bmp', audience: 'person' } + }) + ).toBe('The provider did not accept this message: Image type .bmp.') + expect( + notice("Claude couldn't start. Start a new chat to continue.", { + kind: 'startFailed', + refusal: { code: 'agent_session_identity_required' } + }) + ).toBe("Claude couldn't start. Start a new chat to continue.") + expect(notice(null, { kind: 'providerRejected' })).toBe( + 'The provider did not accept this message.' + ) + }) + + it('says only that the message was not sent for a fact no message can carry', () => { + expect(notice('Compaction failed.', { kind: 'compactionFailed' })).toBe( + 'Your message was not sent.' + ) + }) +}) + describe('what a refusal shows the user', () => { it('keeps the refusal as a fact on the message, without the host diagnostic', () => { const disposition = disposeStructuredAgentSessionSendResult({ @@ -144,6 +246,57 @@ describe('what a refusal shows the user', () => { ).toBe('Your message was not sent.') }) + it("keeps the reason the host named, and says it on the message's Retry row", () => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: { + ok: false, + refusal: { + code: 'agent_session_conflict', + message: 'The chat is still starting.', + details: { reason: 'chatStarting' } + } + }, + createOperationId: () => 'unused' + }) + + const lastFailure = disposition.entries[0]?.lastFailure + expect(lastFailure).toEqual({ + kind: 'refused', + code: 'agent_session_conflict', + details: { reason: 'chatStarting' } + }) + expect( + agentSessionWriteNoticeEnglish(structuredAgentSessionAttemptFailureParts(lastFailure!)) + ).toBe( + 'The agent is still starting. Your message was not sent. Wait for the agent to finish starting.' + ) + }) + + it("keeps a rejected message's typed fact without the provider's detail", () => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: rejectedWith('An image on this message is empty, so the message was not sent.', { + rejection: { + kind: 'attachmentInvalid', + attachment: { reason: 'empty' }, + detail: { text: 'image block 2: zero bytes', audience: 'log' } + } + }), + createOperationId: () => 'unused' + }) + + expect(disposition.entries[0]?.lastFailure).toEqual({ + kind: 'rejected', + reason: 'An image on this message is empty, so the message was not sent.', + rejection: { kind: 'attachmentInvalid', attachment: { reason: 'empty' } } + }) + }) + it('keeps a failed request as a fact, not a transport error string', () => { const disposition = disposeStructuredAgentSessionSendFailure({ entries: [entry], diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 11ba9703b83..11e2b45fc0b 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -10,19 +10,22 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionMutationResult, AgentSessionSendResult } from './agent-session-wire' import { - agentSessionRefusalFailure, agentSessionWriteNoticeEnglish, agentSessionWriteNoticeParts, - agentSessionWriteNotDoneParts, - type AgentSessionWriteNoticePart + agentSessionWriteNotDoneParts } from './agent-session-refusal-notice' +import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' +import { agentSessionRefusalFailure } from './agent-session-write-failure' +import type { AgentSessionFailureFact } from './agent-session-failure' import { - dispatchRejectionReasonIsInternal, - dispatchRejectionWasTransportWriteFailure -} from './structured-agent-session-dispatch-rejection' + agentSessionFailureSentence, + type AgentSessionFailureWordsContext +} from './agent-session-failure-words' +import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, requeueStructuredAgentSessionSendRefusal, + structuredAgentSessionRejectedFailure, type StructuredAgentSessionAttemptFailure, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' @@ -34,8 +37,6 @@ export type StructuredAgentSessionSendDisposition = { /** The entry the queue is stuck on, or null when nothing blocks it. Always the * next value, never "unchanged": the caller assigns it verbatim. */ blockedClientMessageId: string | null - /** A rejected result arrived before the journal snapshot; Retry must rotate this id. */ - retryWithFreshClientMessageId: string | null } type SendDispositionInput = { @@ -131,28 +132,63 @@ export function structuredAgentSessionRejectionNotice( export function structuredAgentSessionRejectionParts( reason: string | null, - write: 'send' | 'composer-send' + write: 'send' | 'composer-send', + /** The host's typed fact, which decides when the row carried one. */ + fact?: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext = {} ): AgentSessionWriteNoticePart[] { + if (fact) { + return rejectionFactParts(write, fact, context) + } if (reason === null) { return ['notDoneSend'] } - if (dispatchRejectionWasTransportWriteFailure(reason)) { + const rejection = classifyDispatchRejection({ reason }) + if (rejection.kind === 'writeFailed') { return ['unreachable', ...agentSessionWriteNotDoneParts(write)] } - // Any other reason we minted is an internal cause with no user-facing meaning; - // only a provider's own explanation is worth reading verbatim. - return dispatchRejectionReasonIsInternal(reason) - ? agentSessionWriteNotDoneParts(write) - : [{ text: reason }] + // A legacy marker is an internal cause with no user-facing meaning; any other reason is a + // sentence written to be read — the provider's, or the host's own. + return rejection.kind ? agentSessionWriteNotDoneParts(write) : [{ text: reason }] } +function rejectionFactParts( + write: 'send' | 'composer-send', + fact: AgentSessionFailureFact, + context: AgentSessionFailureWordsContext +): AgentSessionWriteNoticePart[] { + const { kind } = classifyDispatchRejection({ reason: null, rejection: fact }) + if (kind === 'writeFailed') { + return ['unreachable', ...agentSessionWriteNotDoneParts(write)] + } + // A fact this build cannot place proves only that the message did not happen. + return kind + ? [{ text: agentSessionFailureSentence({ ...fact, kind }, 'rejection', context) }] + : agentSessionWriteNotDoneParts(write) +} + +/** Kinds whose words need what the message's copy drops: the provider's detail, or the refusal. */ +const WORDED_FROM_WHOLE_FACT: ReadonlySet = new Set< + AgentSessionFailureFact['kind'] +>(['providerRejected', 'startFailed', 'restartFailed']) + /** What the Retry row says about why its message did not go through. */ export function structuredAgentSessionAttemptFailureParts( - failure: StructuredAgentSessionAttemptFailure + failure: StructuredAgentSessionAttemptFailure, + context: AgentSessionFailureWordsContext = {}, + /** The journal's whole fact for a recorded rejection, when its submission is loaded: the + * message's own copy keeps only its kind and attachment. */ + recorded?: AgentSessionFailureFact ): AgentSessionWriteNoticePart[] { - return failure.kind === 'rejected' - ? structuredAgentSessionRejectionParts(failure.reason, 'send') - : agentSessionWriteNoticeParts(failure, 'send') + if (failure.kind !== 'rejected') { + return agentSessionWriteNoticeParts(failure, 'send', context) + } + const fact = recorded ?? failure.rejection + // Without the journal's fact, the host's sentence still holds what the copy dropped. + if (!recorded && fact && WORDED_FROM_WHOLE_FACT.has(fact.kind) && failure.reason !== null) { + return [{ text: failure.reason }] + } + return structuredAgentSessionRejectionParts(failure.reason, 'send', fact, context) } export function disposeStructuredAgentSessionSendResult( @@ -166,16 +202,17 @@ export function disposeStructuredAgentSessionSendResult( const refusedIndex = input.entries.findIndex( (candidate) => candidate.clientMessageId === input.entry.clientMessageId ) + const refusal = agentSessionRefusalFailure(result.refusal) const entries = input.entries.map((candidate) => candidate.clientMessageId === input.entry.clientMessageId ? withLastFailure( requeueStructuredAgentSessionSendRefusal( candidate, - result.refusal.code, + refusal, input.createOperationId, input.entry.lastAttemptAt !== null ), - agentSessionRefusalFailure(result.refusal) + refusal ) : candidate ) @@ -189,8 +226,7 @@ export function disposeStructuredAgentSessionSendResult( blockedClientMessageId: !refused || refused.state === 'rejected' ? input.blockedClientMessageId - : refused.clientMessageId, - retryWithFreshClientMessageId: null + : refused.clientMessageId } } const submission = result.value.submission @@ -198,27 +234,37 @@ export function disposeStructuredAgentSessionSendResult( return { entries: dropEntry(input), error: 'Message delivery is unconfirmed and Orca will not send it again', - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId } } if (submission.dispatchState === 'accepted') { return { entries: dropEntry(input), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId + } + } + // A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the reconcile + // drops it, with no notice. + if ( + submission.dispatchState === 'rejected' && + classifyDispatchRejection(submission).category === 'withdrawn' + ) { + return { + entries: dropEntry(input), + error: null, + blockedClientMessageId: input.blockedClientMessageId } } if (submission.dispatchState === 'rejected') { return { - entries: replaceEntryState(input, 'rejected', { - kind: 'rejected', - reason: submission.reason - }), + entries: replaceEntryState( + input, + 'rejected', + structuredAgentSessionRejectedFailure(submission) + ), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: input.entry.clientMessageId + blockedClientMessageId: input.blockedClientMessageId } } if (submission.dispatchState === 'unknown' && submission.recovered) { @@ -229,8 +275,7 @@ export function disposeStructuredAgentSessionSendResult( : candidate ), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId } } // `pending` is the host saying the message was written and is awaiting the @@ -245,8 +290,7 @@ export function disposeStructuredAgentSessionSendResult( submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching' ), error: null, - blockedClientMessageId: input.blockedClientMessageId, - retryWithFreshClientMessageId: null + blockedClientMessageId: input.blockedClientMessageId } } @@ -266,7 +310,6 @@ export function disposeStructuredAgentSessionSendFailure( error: null, blockedClientMessageId: deliveryUnknown ? input.blockedClientMessageId - : input.entry.clientMessageId, - retryWithFreshClientMessageId: null + : input.entry.clientMessageId } } diff --git a/src/shared/structured-agent-session-start-failure-row-key.ts b/src/shared/structured-agent-session-start-failure-row-key.ts new file mode 100644 index 00000000000..6dc4a4817b1 --- /dev/null +++ b/src/shared/structured-agent-session-start-failure-row-key.ts @@ -0,0 +1,17 @@ +// A failed start's one row is keyed by the start, so a reader finds it by identity, not by its words. + +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from './agent-session-journal-types' + +const START_FAILURE_ROW = 'start-failure:' + +export function structuredAgentSessionStartFailureRowIdentity( + startKey: string +): Extract { + return { provider: 'orca', clientMessageId: `${START_FAILURE_ROW}${startKey}` } +} + +export function isStructuredAgentSessionStartFailureRow(itemId: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + return identity?.provider === 'orca' && identity.clientMessageId.startsWith(START_FAILURE_ROW) +} diff --git a/src/shared/structured-agent-session-status-block.ts b/src/shared/structured-agent-session-status-block.ts new file mode 100644 index 00000000000..58dbbe9131c --- /dev/null +++ b/src/shared/structured-agent-session-status-block.ts @@ -0,0 +1,18 @@ +import { readAgentSessionFailureFact } from './agent-session-failure' +import type { AgentJournalStatusItem } from './agent-session-journal-types' +import type { NativeChatTextBlock } from './native-chat-types' + +/** A status row as the line a chat paints: named fields only, so a host-only key never leaks. */ +export function structuredAgentSessionStatusBlock( + body: AgentJournalStatusItem +): NativeChatTextBlock { + const failure = readAgentSessionFailureFact(body.failure) + return { + type: 'text', + text: body.text, + ...(body.presentation !== undefined ? { presentation: body.presentation } : {}), + ...(body.tone !== undefined ? { tone: body.tone } : {}), + ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}), + ...(failure ? { failure } : {}) + } +} diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index 903af48e959..cee76b677d6 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -19,6 +19,7 @@ export const AGENT_KIND_VALUES = [ 'pi', 'omp', 'prime-agent', + 'qoder', 'gemini', 'antigravity', 'aider', @@ -30,6 +31,7 @@ export const AGENT_KIND_VALUES = [ 'aug', 'cline', 'codebuff', + 'freebuff', 'command-code', 'continue', 'cursor', @@ -46,6 +48,7 @@ export const AGENT_KIND_VALUES = [ 'ante', 'trae', 'muse', + 'dsh', 'zcode', 'other' ] as const diff --git a/src/shared/terminal-input-kind.ts b/src/shared/terminal-input-kind.ts new file mode 100644 index 00000000000..f9cef0e2a18 --- /dev/null +++ b/src/shared/terminal-input-kind.ts @@ -0,0 +1,8 @@ +/** + * Why bytes are written to a PTY. Every host write entry point takes one, so no producer inherits + * a transport's default. + * - `driving`: input that drives the run, from a person or any client, driver or agent acting on it. + * - `launch`: bytes Orca writes to start the run: startup commands, a create-time draft or prompt. + * - `query-reply`: the terminal's automatic answer to a query the running program made. + */ +export type TerminalInputKind = 'driving' | 'launch' | 'query-reply' diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts index 91fab874a78..7dbf7b94e1a 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts @@ -1,7 +1,7 @@ import { Terminal } from '@xterm/headless' import { describe, expect, it } from 'vitest' import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker' -import { POST_REPLAY_REATTACH_RESET } from './terminal-mode-reset-profiles' +import { CONFIRMED_SHELL_MODE_RESET } from './terminal-mode-reset-profiles' describe('TerminalKittyKeyboardModeTracker', () => { it('starts inactive and ignores non-kitty sequences', () => { @@ -71,7 +71,7 @@ describe('TerminalKittyKeyboardModeTracker', () => { }) // Why: a confirmed-shell reset lands between live chunks in both records, so they must agree. - it('agrees with xterm when the reattach reset interrupts a split live sequence', async () => { + it('agrees with xterm when the confirmed-shell reset interrupts a split live sequence', async () => { const term = new Terminal({ allowProposedApi: true, vtExtensions: { kittyKeyboard: true } }) const tracker = new TerminalKittyKeyboardModeTracker() const replies: string[] = [] @@ -87,7 +87,7 @@ describe('TerminalKittyKeyboardModeTracker', () => { } await feed('\x1b[>5u\x1b[?1049h\x1b[>3u\x1b[>') - await feed(POST_REPLAY_REATTACH_RESET) + await feed(CONFIRMED_SHELL_MODE_RESET) await feed('1u') expect(tracker.flags).toBe(0) expect(await xtermReport()).toBe('\x1b[?0u') @@ -344,4 +344,14 @@ describe('TerminalKittyKeyboardModeTracker', () => { expect(softReset.hasProvenBaseline).toBe(true) }) }) + + // Why: xterm ignores CSI u while the protocol is withheld, so the mirror must stay at 0 too. + it('ignores kitty sequences and snapshot flags when the protocol is withheld', () => { + const tracker = new TerminalKittyKeyboardModeTracker({ kittyKeyboard: false }) + tracker.scan('\x1b[>5u\x1b[=31u') + expect(tracker.flags).toBe(0) + tracker.resetForSnapshot() + tracker.restoreSnapshotFlags(31) + expect(tracker.flags).toBe(0) + }) }) diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.ts index 880d1fb5cf7..6b2b0e26f83 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.ts @@ -19,13 +19,11 @@ type KittyStackFrame = { flags: number; known: boolean } * DECSET/DECRST 47/1047/1049, the full reset on RIS, and the soft reset on * DECSTR (CSI ! p). * - * Why a mirror instead of reading xterm's internal state: Orca defensively - * wipes the renderer terminal's kitty flags at moments when the TUI may have - * died (Ctrl+C interrupts, reattach resets) while the TUI is usually still - * alive and expecting protocol-encoded input. This tracker is fed by - * application output, snapshot restores, and the Orca resets that also reach - * xterm (ConPTY agent-idle, confirmed shell), never by the renderer's other - * defensive xterm writes, so it reflects what the live application negotiated. + * Why a mirror instead of reading xterm's internal state: xterm's public API + * exposes no kitty flags. The renderer scans application output as it queues + * it and every mode write of its own; wherever xterm may skip or discard + * scanned bytes (snapshot replays, abandoned restores) the renderer re-asserts + * the mirror's flags into xterm, so their active-screen flags converge. * The daemon reuses it to carry flags into snapshots (xterm's SerializeAddon does * not serialize kitty state). */ @@ -49,6 +47,12 @@ export class TerminalKittyKeyboardModeTracker { // one. Grounding flips on evidence only: an explicit fresh-PTY reset, a // proven snapshot restore, or scanned bytes that state flags absolutely. private baselineProven = false + private readonly kittyKeyboard: boolean + + /** `kittyKeyboard: false` mirrors an xterm with the protocol withheld, which ignores `CSI u`. */ + constructor(options: { kittyKeyboard?: boolean } = {}) { + this.kittyKeyboard = options.kittyKeyboard ?? true + } /** * Current effective kitty keyboard flags. `0` doubles as the conservative @@ -129,7 +133,7 @@ export class TerminalKittyKeyboardModeTracker { */ restoreSnapshotFlags(flags: number): void { const parsed = parseTerminalKittyKeyboardFlags(flags) - if (parsed === undefined) { + if (parsed === undefined || !this.kittyKeyboard) { return } this.currentFlags = parsed @@ -184,7 +188,9 @@ export class TerminalKittyKeyboardModeTracker { this.applyScreenSwitch(match[1], match[2] === 'h') continue } - this.applyKittySequence(match[3], match[4] ?? '', replay) + if (this.kittyKeyboard) { + this.applyKittySequence(match[3], match[4] ?? '', replay) + } } } diff --git a/src/shared/terminal-mode-reset-profiles.test.ts b/src/shared/terminal-mode-reset-profiles.test.ts index d3de25ea4fe..203d836a3f8 100644 --- a/src/shared/terminal-mode-reset-profiles.test.ts +++ b/src/shared/terminal-mode-reset-profiles.test.ts @@ -10,9 +10,13 @@ import { PROCESS_BOUNDARY_GROUND, RESET_GRAPHIC_RENDITION, RESET_MOUSE_REPORTING, + buildKittyKeyboardRestore, buildPostReplayLiveAgentReattachReset, + CONFIRMED_SHELL_MODE_RESET, + POST_REPLAY_DEAD_TUI_RESET, replayPayloadEndsWithCursorHidden } from './terminal-mode-reset-profiles' +import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker' // Why literal expectations: consumers import these constants, so only a byte-level // assertion here can catch a profile silently losing a mode it is meant to clear. @@ -31,7 +35,7 @@ describe('terminal mode reset profiles', () => { it('pins the daemon-reattach profile, which keeps bracketed paste', () => { expect(POST_REPLAY_REATTACH_RESET).toBe( - '\x1b[0m\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b7' + '\x1b[0m\x1b[0 q\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b7' ) expect(POST_REPLAY_REATTACH_RESET).toContain(RESET_GRAPHIC_RENDITION) expect(POST_REPLAY_REATTACH_RESET).not.toContain('\x1b[?2004l') @@ -41,9 +45,7 @@ describe('terminal mode reset profiles', () => { // so this profile can reach a plain shell, where armed focus reporting rings BEL on every pane // switch. Dropping it would also make this byte-identical to the live-agent profile. it('pins the live alternate-screen profile, which keeps mouse reporting but not focus', () => { - expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).toBe( - '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?1004l' - ) + expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).toBe('\x1b[0 q\x1b[?25h\x1b[?1004l') expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).not.toContain(RESET_MOUSE_REPORTING) expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).not.toBe(POST_REPLAY_LIVE_AGENT_REATTACH_RESET) }) @@ -87,7 +89,7 @@ describe('terminal mode reset profiles', () => { // coverage asserts they were passed through unchanged — need a literal here. it('pins the live-snapshot and live-agent profiles', () => { expect(POST_REPLAY_LIVE_SNAPSHOT_RESET).toBe('\x1b[0 q\x1b[?25h\x1b[?1004l') - expect(POST_REPLAY_LIVE_AGENT_REATTACH_RESET).toBe('\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h') + expect(POST_REPLAY_LIVE_AGENT_REATTACH_RESET).toBe('\x1b[0 q\x1b[?25h') expect(POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET).toBe('\x1b[0 q') }) @@ -116,4 +118,57 @@ describe('terminal mode reset profiles', () => { expect(buildPostReplayLiveAgentReattachReset('x\x1b[?25h')).toContain('\x1b[?25h') }) }) + + // Why: a live reattach must keep the app's negotiated flags; the epilogue restores them instead. + it('keeps Kitty resets out of every live reattach profile', () => { + for (const profile of [ + POST_REPLAY_REATTACH_RESET, + POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, + POST_REPLAY_DEAD_TUI_RESET, + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, + buildPostReplayLiveAgentReattachReset('x\x1b[?25l') + ]) { + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + expect(profile).not.toMatch(/\x1b\[[<=>][0-9;]*u/) + } + expect(CONFIRMED_SHELL_MODE_RESET).toBe(`${POST_REPLAY_REATTACH_RESET}\x1b[<99u\x1b[=0u`) + }) + + it('restores proven Kitty flags and only pops unproven ones', () => { + expect(buildKittyKeyboardRestore(5)).toBe('\x1b[<99u\x1b[=5u') + expect(buildKittyKeyboardRestore(undefined)).toBe('\x1b[<99u') + }) + + // Why: xterm pushes a relay-redelivered push again while the mirror applies it as a set, so + // without the pop-all the app's single pop would leave xterm on the stale frame. + it("brings xterm and the mirror to 0 on the app's pop after a redelivered push", async () => { + const term = new Terminal({ allowProposedApi: true, vtExtensions: { kittyKeyboard: true } }) + const mirror = new TerminalKittyKeyboardModeTracker() + const replies: string[] = [] + term.onData((data) => replies.push(data)) + const write = (data: string): Promise => + new Promise((resolve) => term.write(data, resolve)) + const xtermFlags = async (): Promise => { + replies.length = 0 + await write('\x1b[?u') + return replies.join('') + } + for (const redelivery of ['\x1b[>1u', '\x1b[>1u']) { + mirror.scanReplay(redelivery) + await write(redelivery) + } + const restore = buildKittyKeyboardRestore(mirror.snapshotFlags) + mirror.scan(restore) + await write(restore) + expect(mirror.flags).toBe(1) + expect(await xtermFlags()).toBe('\x1b[?1u') + + mirror.scan('\x1b[ string | null = memoizeTitleClassification(computeAgentLabel) const TITLE_LABEL_TO_AGENT: Partial> = { + 'DeepSeek Harness': 'dsh', 'Claude Code': 'claude', OpenClaude: 'openclaude', Codex: 'codex', + 'Qoder CLI': 'qoder', 'Gemini CLI': 'gemini', 'GitHub Copilot': 'copilot', Grok: 'grok', diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 55082bc2470..15318913968 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -14,6 +14,7 @@ export type DraftPasteReadySignal = | 'codex-composer-prompt' | 'render-cursor-after-bracketed-paste' | 'grok-composer-prompt' + | 'dsh-composer-prompt' | 'zcode-composer-prompt' export type TuiAgentDetectionRuntime = NodeJS.Platform | 'wsl' @@ -40,7 +41,7 @@ export type TuiAgentConfig = { /** Claude Code follows pasted text only where the user's typed words ask, so dispatch briefs need a typed lead line. */ pasteNeedsTypedRequest?: boolean /** Pre-write a trust artifact so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste (see agent-trust-presets.ts). */ - preflightTrust?: 'cursor' | 'copilot' | 'codex' | 'antigravity' + preflightTrust?: 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' /** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */ draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ @@ -183,6 +184,11 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // Why: Prime Agent embeds Pi's TUI and decodes CSI-u the same way (see pi above). windowsShiftEnterEncoding: 'csi-u' }, + qoder: { + detectCmd: 'qodercli', + promptInjectionMode: 'flag-prompt-interactive', + preflightTrust: 'qoder' + }, gemini: { detectCmd: 'gemini', promptInjectionMode: 'flag-prompt-interactive' @@ -235,6 +241,10 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { detectCmd: 'cline', promptInjectionMode: 'stdin-after-start' }, + freebuff: { + detectCmd: 'freebuff', + promptInjectionMode: 'stdin-after-start' + }, codebuff: { detectCmd: 'codebuff', promptInjectionMode: 'stdin-after-start' @@ -322,6 +332,24 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // Muse 1.3 treats subcommand-shaped prompts as commands even after `--`. promptInjectionMode: 'stdin-after-start' }, + dsh: { + // Why: DeepSeek Harness publishes one binary (`dsh`) that boots a profile, and only the + // `dsh-tui` profile paints a composer. `dsh-tui` (alias `dst`) is the launcher that + // selects it, so detect that and require `dsh` too — the launcher delegates to it and + // fails without it. + detectCmd: 'dsh-tui', + detectCmdAliases: ['dst'], + detectRequiredCommands: ['dsh'], + // Why: the launcher re-execs `dsh --profile dsh-tui`, so the pane's foreground process + // is `dsh`, never `dsh-tui`. Readiness and follow-up delivery key off this name. + expectedProcess: 'dsh', + // Why: the terminal app parses only `--resume`/`--continue` and a workspace target; it + // has no prompt flag, so the first prompt is pasted into the composer after startup. + promptInjectionMode: 'stdin-after-start', + // Why: DSH-TUI animates a whale intro continuously behind its composer, so the default + // quiet window never settles (the grok failure mode). See dsh-tui-ready-no-key.txt. + draftPasteReadySignal: 'dsh-composer-prompt' + }, zcode: { detectCmd: 'zcode', // Why: ZCode's entrypoint sets `process.title = 'zcode-cli'` (its `process-name.ts` diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index f85a87834b2..a90daa3242f 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -14,6 +14,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { ante: 'Ante', trae: 'Trae', muse: 'Muse', + dsh: 'DeepSeek Harness', zcode: 'ZCode', autohand: 'Autohand Code', opencode: 'OpenCode', @@ -22,6 +23,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { pi: 'Pi', omp: 'OMP', 'prime-agent': 'Prime Agent', + qoder: 'Qoder CLI', gemini: 'Gemini', antigravity: 'Antigravity', aider: 'Aider', @@ -33,6 +35,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { aug: 'Auggie', cline: 'Cline', codebuff: 'Codebuff', + freebuff: 'Freebuff', 'command-code': 'Command Code', continue: 'Continue', cursor: 'Cursor', diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 6d6a6bec856..3a9912bf066 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -8,6 +8,7 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = { 'claude-agent-teams': '--dangerously-skip-permissions', openclaude: '--dangerously-skip-permissions', codex: '--dangerously-bypass-approvals-and-sandbox', + qoder: '--dangerously-skip-permissions', gemini: '--yolo', antigravity: '--dangerously-skip-permissions', aider: '--yes-always', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index f3eb7322fb1..85567dcb334 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -16,6 +16,8 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'ante', 'trae', 'muse', + 'dsh', + 'qoder', 'zcode', 'pi', 'omp', @@ -32,6 +34,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'autohand', 'cline', 'codebuff', + 'freebuff', 'command-code', 'continue', 'cursor', diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index d19fcb0b1ce..19722220888 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -11,6 +11,7 @@ export type TuiAgent = | 'mimo-code' | 'pi' // Pi (pi.dev) | 'omp' // OMP (omp.sh) + | 'qoder' // Qoder CLI | 'gemini' // Gemini CLI | 'antigravity' // Google Antigravity CLI | 'aider' // Aider @@ -22,6 +23,7 @@ export type TuiAgent = | 'aug' // Augment/Auggie | 'cline' // Cline | 'codebuff' // Codebuff + | 'freebuff' // Freebuff | 'command-code' // Command Code | 'continue' // Continue | 'cursor' // Cursor @@ -40,3 +42,4 @@ export type TuiAgent = | 'muse' // Muse (Meta `muse` CLI) | 'zcode' // ZCode (Z.ai `zcode` CLI) | 'prime-agent' // Prime Agent (Prime Intellect) + | 'dsh' // DeepSeek Harness (`dsh`, launched through its `dsh-tui` terminal profile) diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index d9aee820949..8d55b56f3e5 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -63,6 +63,7 @@ export type StatusBarItem = | 'minimax' | 'grok' | 'cursor' + | 'zcode' | 'ssh' | 'resource-usage' | 'ports' diff --git a/src/shared/workspace-session-schema-field-coverage.test.ts b/src/shared/workspace-session-schema-field-coverage.test.ts index 28572703d5a..c7001060b7b 100644 --- a/src/shared/workspace-session-schema-field-coverage.test.ts +++ b/src/shared/workspace-session-schema-field-coverage.test.ts @@ -91,13 +91,16 @@ describe('workspaceSessionStateSchema field coverage', () => { const parsed = parseWorkspaceSession({ ...MINIMAL_SESSION, closedTerminalTabTombstonesByTabId: { - 'tab-1': { closedAt: 1_700_000_000_000, worktreeId: 'repo:wt-1', ackRevision: 4 } + 'tab-1': { closedAt: 1_700_000_000_000, worktreeId: 'repo:wt-1', reason: 'cleanup' }, + // An older build's acknowledgement stamp is dropped; the record itself survives. + 'tab-2': { closedAt: 1_700_000_000_000, worktreeId: 'repo:wt-1', ackRevision: 4 } } }) expect(parsed.ok).toBe(true) expect(parsed.ok && parsed.value.closedTerminalTabTombstonesByTabId).toEqual({ - 'tab-1': { closedAt: 1_700_000_000_000, worktreeId: 'repo:wt-1', ackRevision: 4 } + 'tab-1': { closedAt: 1_700_000_000_000, worktreeId: 'repo:wt-1', reason: 'cleanup' }, + 'tab-2': { closedAt: 1_700_000_000_000, worktreeId: 'repo:wt-1' } }) }) diff --git a/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts new file mode 100644 index 00000000000..f7f23d528aa --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import { structuredAgentSessionWorkingAtStop } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown' +import { + journal, + NOW, + record, + SESSION, + TEARDOWN_CURRENT, + turnItem +} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness' +import { AgentSessionRecoveryCapsule } from '../../../src/main/runtime/agent-session-recovery-capsule' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A release whose marker parser still requires the chat's newest message id. +const BASELINE_REF = 'v1.4.211' + +test('an older build reads the restart offer this build records at quit', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-resume-marker-downgrade-')) + try { + // This build: the offer teardown takes for a chat whose turn was running. + const marker = structuredAgentSessionWorkingAtStop({ + sessionId: SESSION, + session: { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }, + getRecord: () => record(), + backgroundTasks: () => undefined, + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + if (!marker) { + throw new Error('this build recorded no offer for a running turn') + } + await new AgentSessionRecoveryCapsule(directory).record([marker], NOW) + + // The older build, after a downgrade, still lists it. + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/main/runtime/agent-session-recovery-capsule.ts' + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the constructor and `list` called below; a missing one fails the test. + const OldCapsule = baseline.AgentSessionRecoveryCapsule as new (directory: string) => { + list: (now: number) => Promise<{ sessionId: string }[]> + } + expect(await new OldCapsule(directory).list(NOW)).toMatchObject([{ sessionId: SESSION }]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 193216e3c44..c52cbfcca03 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -795,11 +795,10 @@ describe('cross-version structured agent sessions', () => { // a rejection that arrives after it. So it is answered once the message is handed over, while // a client that advertises accepted sends is answered at acceptance, start or no start (W9). it('holds the send reply of a released client until the handover, and answers a current one at once', async () => { - const released = [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY - ] - expect(baseline.capabilities).not.toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) + // Why: the baseline is the newest release, which will itself carry accepted sends. + const released = baseline.capabilities.filter( + (capability) => capability !== AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + ) const created = await answer('agentSession.create', createIntentParams()) await bootHost('b') let open = (): void => undefined @@ -835,11 +834,11 @@ describe('cross-version structured agent sessions', () => { result: { value: { submission: { dispatchState: 'pending', handoverRecorded: true } } } }) open() - await vi.waitFor(() => + await vi.waitFor(async () => expect( - restarted - .journalSnapshot(SESSION) - .submissions.every((row) => row.dispatchState !== 'pending' || row.handedOverAt) + (await restarted.journalSnapshot(SESSION)).submissions.every( + (row) => row.dispatchState !== 'pending' || row.handedOverAt + ) ).toBe(true) ) }) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index d8c8b80b329..f827d3cda5f 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -115,7 +115,8 @@ export const STRUCTURED_CALLS: { hostMethod: 'revealSession', result: { ok: true, sessionId: SESSION, workspaceId: WORKSPACE, agent: 'codex', readable: true } }, - { method: 'agentSession.hold', hostMethod: 'hold', result: { held: true } }, + // A no-op on a host that starts an agent only for work; it still builds the host. + { method: 'agentSession.hold', hostMethod: null, result: { held: true } }, // The restart-resume surface. Bare additions, not capability-negotiated: an RPC method's // absence is explicit (`method_not_found`), which the old-dispatcher case below asserts, so a // newer client learns it during negotiation instead of by being met with silence. @@ -129,17 +130,14 @@ export const STRUCTURED_CALLS: { hostMethod: 'restartResumableDismiss', result: { dismissed: 0 } }, - { - method: 'agentSession.restartResume', - hostMethod: 'restartResumeAll', - result: { results: [] } - }, + // Reattaching alone is nothing now, so this answers that nothing was resumed. + { method: 'agentSession.restartResume', hostMethod: null, result: { results: [] } }, { method: 'agentSession.restartContinue', hostMethod: 'restartContinueAll', result: { resumed: [], continued: [] } }, - { method: 'agentSession.release', hostMethod: 'release', result: { released: true } }, + { method: 'agentSession.release', hostMethod: null, result: { released: true } }, { method: 'agentSession.history', hostMethod: 'history', diff --git a/tests/e2e/helpers/terminal-kitty-keyboard.ts b/tests/e2e/helpers/terminal-kitty-keyboard.ts new file mode 100644 index 00000000000..61b298a4c3e --- /dev/null +++ b/tests/e2e/helpers/terminal-kitty-keyboard.ts @@ -0,0 +1,122 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' +import { execInTerminal, focusActiveTerminalInput } from './terminal' +import { clearTerminalPtyWriteLog, readTerminalPtyWrites } from './terminal-pty-write-spy' + +async function dispatchCtrlCToActiveTerminalTextarea( + page: Page, + options: { keyupCtrlKey?: boolean } = {} +): Promise<{ + keydownDefaultPrevented: boolean + keyupDefaultPrevented: boolean +}> { + return page.evaluate((dispatchOptions) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const textarea = pane?.container.querySelector( + '.xterm-helper-textarea' + ) as HTMLTextAreaElement | null + if (!pane || !textarea) { + throw new Error('No active terminal textarea for Ctrl+C dispatch') + } + pane.terminal.clearSelection() + pane.terminal.focus() + textarea.focus() + + const createEvent = (type: 'keydown' | 'keyup', ctrlKey: boolean): KeyboardEvent => { + const event = new KeyboardEvent(type, { + key: 'c', + code: 'KeyC', + ctrlKey, + bubbles: true, + cancelable: true + }) + Object.defineProperty(event, 'keyCode', { get: () => 67 }) + Object.defineProperty(event, 'which', { get: () => 67 }) + return event + } + + // Why: Electron headless consumes real Ctrl+C before xterm in automation; + // synthetic DOM events still exercise Orca's installed xterm boundary. + const keydown = createEvent('keydown', true) + textarea.dispatchEvent(keydown) + const keyup = createEvent('keyup', dispatchOptions.keyupCtrlKey !== false) + textarea.dispatchEvent(keyup) + return { + keydownDefaultPrevented: keydown.defaultPrevented, + keyupDefaultPrevented: keyup.defaultPrevented + } + }, options) +} + +export async function getKittyKeyboardFlags(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const terminal = pane?.terminal as + | { + core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + } + | undefined + return ( + terminal?.core?.coreService?.kittyKeyboard?.flags ?? + terminal?._core?.coreService?.kittyKeyboard?.flags ?? + null + ) + }) +} + +// Why arm from the PTY: the mirror the shortcut policy reads only sees application output. +export async function armKittyKeyboardFromPty( + page: Page, + ptyId: string, + command: string +): Promise { + await execInTerminal(page, ptyId, command) + await expect + .poll(async () => await getKittyKeyboardFlags(page), { + timeout: 15_000, + message: 'the application never armed kitty keyboard reporting' + }) + .toBe(31) +} + +export async function interruptAndExpectEtx(page: Page, app: ElectronApplication): Promise { + await clearTerminalPtyWriteLog(app) + await focusActiveTerminalInput(page) + await page.keyboard.down('Control') + await page.keyboard.up('Control') + expect((await readTerminalPtyWrites(app)).join('')).toBe('') + await clearTerminalPtyWriteLog(app) + + expect(await dispatchCtrlCToActiveTerminalTextarea(page, { keyupCtrlKey: false })).toEqual({ + keydownDefaultPrevented: false, + keyupDefaultPrevented: false + }) + await expect + .poll(async () => (await readTerminalPtyWrites(app)).some((write) => write.includes('\x03')), { + timeout: 5_000, + message: 'Ctrl+C did not reach the PTY as ETX' + }) + .toBe(true) + const writes = (await readTerminalPtyWrites(app)).join('') + expect(writes).not.toContain('\x1b[99;5u') + expect(writes).not.toContain('\x1b[99') +} diff --git a/tests/e2e/sidebar-measured-row-scroll.spec.ts b/tests/e2e/sidebar-measured-row-scroll.spec.ts index a328d9fac70..84c21cd92d1 100644 --- a/tests/e2e/sidebar-measured-row-scroll.spec.ts +++ b/tests/e2e/sidebar-measured-row-scroll.spec.ts @@ -71,6 +71,8 @@ test('keeps a collapsed group visible when an idle card below it grows', async ( orcaPage, registerPostElectronShutdownCleanup }, testInfo) => { + // Why: the fixture's collapse slides rows in from their old position; a hidden window can freeze that mid-flight baseline. + await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) await waitForSessionReady(orcaPage) await orcaPage.setViewportSize({ width: 1200, height: 900 }) const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-sidebar-measured-row-'))) diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index f2c7cbd39d4..3d8858b3658 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -17,6 +17,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { STRUCTURED_AGENT_SESSION_IDLE_MS } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep' import { AgentSessionRecordStore } from '../../src/main/runtime/agent-session-record-store' import { useAppStore } from '../../src/renderer/src/store' import { runWorktreeAgentActivationGate } from '../../src/renderer/src/lib/worktree-agent-activation-gate' @@ -24,9 +25,9 @@ import { readWorktreeStructuredActivationInventory } from '../../src/renderer/sr import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' const WORKTREE = 'repo-1::/workspace/repo' -const SURFACE = 'desktop-chat:1' let root: string +let clock: number let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let closeSession: Mock> @@ -55,9 +56,9 @@ function openHost(): void { journalRoot: root, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', - releaseGraceMs: 5, + idleSweep: { intervalMs: 5 }, probeOwner: async () => ({ outcome: 'pid-absent' }), - now: () => NOW + now: () => clock }) } @@ -109,6 +110,7 @@ function activate(): ReturnType { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-owner-status-')) + clock = NOW resetHostTestOperationIds() closeSession = vi.fn(async () => true) store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) @@ -126,9 +128,8 @@ afterEach(async () => { }) describe('a chat at rest keeps its worktree activatable', () => { - it('after idle release forgot the session', async () => { - await host.hold(SESSION, SURFACE) - host.release(SESSION, SURFACE) + it('after the idle sweep stopped its agent and closed the conversation', async () => { + clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) expect(closeSession).toHaveBeenCalledWith(SESSION) diff --git a/tests/e2e/terminal-kitty-reattach-parity.spec.ts b/tests/e2e/terminal-kitty-reattach-parity.spec.ts new file mode 100644 index 00000000000..bb328a2dd27 --- /dev/null +++ b/tests/e2e/terminal-kitty-reattach-parity.spec.ts @@ -0,0 +1,119 @@ +/** + * A live app's kitty keyboard flags survive park and reveal in both records: + * xterm (which encodes keys) and the pane mirror (which Orca's shortcut policy + * reads). The reveal builds a fresh xterm, so without the replay epilogue's + * restore xterm would sit at 0 while the mirror adopted the host's flags. + */ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + focusActiveTerminalInput, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + clearTerminalPtyWriteLog, + installTerminalPtyWriteSpy, + readTerminalPtyWrites +} from './helpers/terminal-pty-write-spy' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 +const APP_FLAGS = 5 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +async function readXtermKittyFlags(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const tabId = state?.activeTabId ?? null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm exposes kitty flags only on its private core; null when absent. + const terminal = pane?.terminal as + | { _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } } + | undefined + return terminal?._core?.coreService?.kittyKeyboard?.flags ?? null + }) +} + +// Why Shift+Enter: the policy emits CSI-u only when the pane mirror reports kitty flags. +async function expectShiftEnterWrite( + page: Page, + app: ElectronApplication, + expected: string +): Promise { + await clearTerminalPtyWriteLog(app) + await focusActiveTerminalInput(page) + await page.keyboard.press('Shift+Enter') + await expect + .poll(async () => (await readTerminalPtyWrites(app)).includes(expected), { + timeout: 5_000, + message: `Shift+Enter did not write ${JSON.stringify(expected)}` + }) + .toBe(true) +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Orca store unavailable') + } + state.setActiveTabType('terminal', window.__store?.getState().activeWorktreeId ?? null) + state.setActiveTab(tabId) + }, tabId) + await expect.poll(() => getActiveTabId(page)).toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) +} + +test("park and reveal keep a live app's kitty flags in xterm and the mirror", async ({ + orcaPage, + electronApp +}) => { + test.skip(process.platform === 'win32', 'ConPTY panes withhold the kitty protocol') + await installTerminalPtyWriteSpy(electronApp) + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const tabId = await getActiveTabId(orcaPage) + if (!tabId) { + throw new Error('no active terminal tab') + } + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + + try { + // The app stays alive through the park, so nothing grounds its flags. + await sendToTerminal(orcaPage, ptyId, `printf '\\033[=${APP_FLAGS}u'; sleep 120\r`) + await expect.poll(() => readXtermKittyFlags(orcaPage)).toBe(APP_FLAGS) + await expectShiftEnterWrite(orcaPage, electronApp, '\x1b[13;2u') + + await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { + parkDelayMs: PARKING_DELAY_MS + }) + await activateTerminalTab(orcaPage, tabId) + await waitForActivePanePtyId(orcaPage) + + await expect + .poll(() => readXtermKittyFlags(orcaPage), { + timeout: 10_000, + message: 'the revealed xterm did not get the live app kitty flags back' + }) + .toBe(APP_FLAGS) + await expectShiftEnterWrite(orcaPage, electronApp, '\x1b[13;2u') + } finally { + await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) + } +}) diff --git a/tests/e2e/terminal-reset-input-modes.spec.ts b/tests/e2e/terminal-reset-input-modes.spec.ts new file mode 100644 index 00000000000..f10797666af --- /dev/null +++ b/tests/e2e/terminal-reset-input-modes.spec.ts @@ -0,0 +1,131 @@ +/** + * Reset Terminal grounds the input modes an app left armed where the host saw + * no command end, and the ground survives park and reveal. The reveal builds a + * fresh xterm from the host's model, so a renderer-only reset would come back + * armed. + */ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + focusActiveTerminalInput, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { openTerminalContextMenu } from './helpers/terminal-pane-title-actions' +import { + clearTerminalPtyWriteLog, + installTerminalPtyWriteSpy, + readTerminalPtyWrites +} from './helpers/terminal-pty-write-spy' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 +const KITTY_SHIFT_ENTER = '\x1b[13;2u' + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +type XtermInputModes = { kittyFlags: number | null; mouse: string | null } + +async function readXtermInputModes(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const tabId = state?.activeTabId ?? null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm exposes kitty flags only on its private core; null when absent. + const terminal = pane?.terminal as + | { + modes?: { mouseTrackingMode?: string } + _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + } + | undefined + return { + kittyFlags: terminal?._core?.coreService?.kittyKeyboard?.flags ?? null, + mouse: terminal?.modes?.mouseTrackingMode ?? null + } + }) +} + +// Why Shift+Enter: the policy emits CSI-u only when the pane mirror reports kitty flags. +async function readShiftEnterWrite(page: Page, app: ElectronApplication): Promise { + await clearTerminalPtyWriteLog(app) + await focusActiveTerminalInput(page) + await page.keyboard.press('Shift+Enter') + let written = '' + await expect + .poll( + async () => { + written = (await readTerminalPtyWrites(app)).join('') + return written.length + }, + { timeout: 5_000, message: 'Shift+Enter wrote nothing' } + ) + .toBeGreaterThan(0) + return written +} + +async function expectGrounded(page: Page, app: ElectronApplication, when: string): Promise { + await expect + .poll(() => readXtermInputModes(page), { timeout: 10_000, message: `xterm armed ${when}` }) + .toEqual({ kittyFlags: 0, mouse: 'none' }) + expect(await readShiftEnterWrite(page, app)).not.toContain(KITTY_SHIFT_ENTER) +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Orca store unavailable') + } + state.setActiveTabType('terminal', window.__store?.getState().activeWorktreeId ?? null) + state.setActiveTab(tabId) + }, tabId) + await expect.poll(() => getActiveTabId(page)).toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) +} + +test('Reset Terminal grounds modes an unhooked crash left armed, through park and reveal', async ({ + orcaPage, + electronApp +}) => { + test.skip(process.platform === 'win32', 'ConPTY panes withhold the kitty protocol') + await installTerminalPtyWriteSpy(electronApp) + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const tabId = await getActiveTabId(orcaPage) + if (!tabId) { + throw new Error('no active terminal tab') + } + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + + // `exec` drops the shell hooks, so no OSC 133;D ever lets the host ground the + // modes the printf arms and leaves behind. + await sendToTerminal(orcaPage, ptyId, 'exec bash --norc --noprofile\r') + await sendToTerminal(orcaPage, ptyId, "printf '\\033[>5u\\033[?1000h\\033[?1006h'\r") + await expect + .poll(() => readXtermInputModes(orcaPage), { timeout: 10_000 }) + .toEqual({ kittyFlags: 5, mouse: 'vt200' }) + expect(await readShiftEnterWrite(orcaPage, electronApp)).toContain(KITTY_SHIFT_ENTER) + + await openTerminalContextMenu(orcaPage) + await orcaPage.getByRole('menuitem', { name: 'Reset Terminal', exact: true }).click() + await expectGrounded(orcaPage, electronApp, 'after Reset Terminal') + + await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS }) + await activateTerminalTab(orcaPage, tabId) + await waitForActivePanePtyId(orcaPage) + await expectGrounded(orcaPage, electronApp, 'after park and reveal') +}) diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index c70ab00567b..0ea9f19b354 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -34,6 +34,11 @@ import { installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, readTerminalPtyWrites as getPtyWrites } from './helpers/terminal-pty-write-spy' +import { + armKittyKeyboardFromPty, + getKittyKeyboardFlags, + interruptAndExpectEtx +} from './helpers/terminal-kitty-keyboard' async function setActivePaneForegroundAgent( page: Page, @@ -65,60 +70,6 @@ async function setActivePaneForegroundAgent( }, agent) } -async function dispatchCtrlCToActiveTerminalTextarea( - page: Page, - options: { keyupCtrlKey?: boolean } = {} -): Promise<{ - keydownDefaultPrevented: boolean - keyupDefaultPrevented: boolean -}> { - return page.evaluate((dispatchOptions) => { - const state = window.__store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const textarea = pane?.container.querySelector( - '.xterm-helper-textarea' - ) as HTMLTextAreaElement | null - if (!pane || !textarea) { - throw new Error('No active terminal textarea for Ctrl+C dispatch') - } - pane.terminal.clearSelection() - pane.terminal.focus() - textarea.focus() - - const createEvent = (type: 'keydown' | 'keyup', ctrlKey: boolean): KeyboardEvent => { - const event = new KeyboardEvent(type, { - key: 'c', - code: 'KeyC', - ctrlKey, - bubbles: true, - cancelable: true - }) - Object.defineProperty(event, 'keyCode', { get: () => 67 }) - Object.defineProperty(event, 'which', { get: () => 67 }) - return event - } - - // Why: Electron headless consumes real Ctrl+C before xterm in automation; - // synthetic DOM events still exercise Orca's installed xterm boundary. - const keydown = createEvent('keydown', true) - textarea.dispatchEvent(keydown) - const keyup = createEvent('keyup', dispatchOptions.keyupCtrlKey !== false) - textarea.dispatchEvent(keyup) - return { - keydownDefaultPrevented: keydown.defaultPrevented, - keyupDefaultPrevented: keyup.defaultPrevented - } - }, options) -} - async function focusFloatingTerminal(page: Page): Promise { await page .locator( @@ -227,53 +178,6 @@ async function getActiveTerminalViewport( }) } -async function enableKittyKeyboardReporting(page: Page, flags: number): Promise { - await page.evaluate(async (flags) => { - const state = window.__store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!pane) { - throw new Error('No active terminal pane for kitty keyboard setup') - } - await new Promise((resolve) => { - pane.terminal.write(`\x1b[=${flags}u`, resolve) - }) - }, flags) -} - -async function getKittyKeyboardFlags(page: Page): Promise { - return page.evaluate(() => { - const state = window.__store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const terminal = pane?.terminal as - | { - core?: { coreService?: { kittyKeyboard?: { flags?: number } } } - _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } - } - | undefined - return ( - terminal?.core?.coreService?.kittyKeyboard?.flags ?? - terminal?._core?.coreService?.kittyKeyboard?.flags ?? - null - ) - }) -} - async function pressShiftedRussianLayoutKey(page: Page): Promise<{ keydownDefaultPrevented: boolean keypressSent: boolean @@ -550,45 +454,55 @@ test.describe('Terminal Shortcuts', () => { await pressAndExpectWrite(orcaPage, electronApp, 'Control+Enter', '\x1b[13;5u') }) - test('plain Ctrl+C sends ETX under kitty keyboard reporting', async ({ + test('plain Ctrl+C sends ETX and leaves a surviving app its kitty flags', async ({ orcaPage, electronApp }) => { + test.skip(process.platform === 'win32', 'POSIX trap and printf fixture') await installMainProcessPtyWriteSpy(electronApp) const ptyId = await waitForActivePanePtyId(orcaPage) // SIGINT during shell startup kills the shell, so interrupt only a ready prompt. await execInTerminal(orcaPage, ptyId, 'echo "CTRL_C_""READY"') await waitForTerminalOutput(orcaPage, 'CTRL_C_READY') - await enableKittyKeyboardReporting(orcaPage, 31) - await clearPtyWriteLog(electronApp) - await focusActiveTerminalInput(orcaPage) - await orcaPage.keyboard.down('Control') - await orcaPage.keyboard.up('Control') - expect((await getPtyWrites(electronApp)).join('')).toBe('') - await clearPtyWriteLog(electronApp) + // An app that survives SIGINT, like an agent TUI, and disarms its own flags on exit. + await armKittyKeyboardFromPty( + orcaPage, + ptyId, + `bash -c 'trap "" INT; printf "\\033[=31u"; read -r _; printf "\\033[=0u"'` + ) - expect(await dispatchCtrlCToActiveTerminalTextarea(orcaPage, { keyupCtrlKey: false })).toEqual({ - keydownDefaultPrevented: false, - keyupDefaultPrevented: false - }) + await interruptAndExpectEtx(orcaPage, electronApp) - await expect - .poll(async () => (await getPtyWrites(electronApp)).some((write) => write.includes('\x03')), { - timeout: 5_000, - message: 'Ctrl+C did not reach the PTY as ETX' - }) - .toBe(true) - const writes = (await getPtyWrites(electronApp)).join('') - expect(writes).not.toContain('\x1b[99;5u') - expect(writes).not.toContain('\x1b[99') + // Orca no longer guesses the app died: xterm keeps encoding for it, and the + // mirror the shortcut policy reads agrees (Shift+Enter stays CSI-u). + expect(await getKittyKeyboardFlags(orcaPage)).toBe(31) + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b[13;2u') + + await sendToTerminal(orcaPage, ptyId, '\r') + await expect.poll(async () => await getKittyKeyboardFlags(orcaPage)).toBe(0) + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') + }) + + test('the host grounds kitty flags an app killed by Ctrl+C left armed', async ({ + orcaPage, + electronApp + }) => { + test.skip(process.platform === 'win32', 'POSIX printf fixture') + await installMainProcessPtyWriteSpy(electronApp) + const ptyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, ptyId, 'echo "CTRL_C_""READY"') + await waitForTerminalOutput(orcaPage, 'CTRL_C_READY') + // `cat` dies on SIGINT with the flags still armed, like a crashed TUI. + await armKittyKeyboardFromPty(orcaPage, ptyId, `printf '\\033[=31u'; cat`) + + await interruptAndExpectEtx(orcaPage, electronApp) await expect .poll(async () => await getKittyKeyboardFlags(orcaPage), { timeout: 5_000, - message: 'Ctrl+C did not clear stale Kitty keyboard flags' + message: 'the host did not ground the dead app kitty flags at command end' }) .toBe(0) - await clearPtyWriteLog(electronApp) await focusActiveTerminalInput(orcaPage) await orcaPage.keyboard.type('x') @@ -598,11 +512,85 @@ test.describe('Terminal Shortcuts', () => { message: 'Post-interrupt keyboard input stayed in Kitty CSI-u mode' }) .toBe(true) - const postInterruptWrites = (await getPtyWrites(electronApp)).join('') - expect(postInterruptWrites).not.toContain('\x1b[') + expect((await getPtyWrites(electronApp)).join('')).not.toContain('\x1b[') await orcaPage.keyboard.press('Backspace') }) + test('unselected Cmd+C reaches a Kitty app, including after Ctrl+C', async ({ + orcaPage, + electronApp + }) => { + test.skip(process.platform !== 'darwin', 'macOS copy binding') + await installMainProcessPtyWriteSpy(electronApp) + const ptyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, ptyId, 'echo "CMD_C_""READY"') + await waitForTerminalOutput(orcaPage, 'CMD_C_READY') + const select = (text: string | null): Promise => + orcaPage.evaluate(async (selectedText) => { + const state = window.__store!.getState() + const pane = window.__paneManagers!.get(state.activeTabId!)!.getActivePane()! + if (selectedText === null) { + pane.terminal.clearSelection() + return '' + } + await new Promise((resolve) => + pane.terminal.write(`\r\n${selectedText}\r\n`, resolve) + ) + const buffer = pane.terminal.buffer.active + pane.terminal.select(0, buffer.baseY + buffer.cursorY - 1, selectedText.length) + return pane.terminal.getSelection() + }, text) + // Presses Cmd+C, then a marker chord whose write proves the Cmd+C bytes had their turn. + const pressCmdC = async (marker: string, markerWrite: string): Promise => { + await clearPtyWriteLog(electronApp) + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press('Meta+c') + await orcaPage.keyboard.press(marker) + await expect.poll(async () => await getPtyWrites(electronApp)).toContain(markerWrite) + const writes = await getPtyWrites(electronApp) + return writes.slice(0, writes.indexOf(markerWrite)) + } + const kittyCmdC = ['\x1b[99;9;99u', '\x1b[99;9:3u'] + const originalClipboard = await electronApp.evaluate(({ clipboard }) => clipboard.readText()) + try { + await select(null) + expect(await pressCmdC('x', 'x')).toEqual([]) + await orcaPage.keyboard.press('Backspace') + + // An app that survives SIGINT, like an agent TUI, and disarms its own flags on exit. + await armKittyKeyboardFromPty( + orcaPage, + ptyId, + `bash -c 'trap "" INT; printf "\\033[=31u"; read -r _; printf "\\033[=0u"'` + ) + await select(null) + expect(await pressCmdC('Shift+Enter', '\x1b[13;2u')).toEqual(kittyCmdC) + + const selectedText = await select('Terminal copy selection') + expect(selectedText).toBe('Terminal copy selection') + await electronApp.evaluate(({ clipboard }) => clipboard.writeText('')) + expect(await pressCmdC('Shift+Enter', '\x1b[13;2u')).toEqual([]) + await expect + .poll(() => electronApp.evaluate(({ clipboard }) => clipboard.readText())) + .toBe(selectedText) + + await interruptAndExpectEtx(orcaPage, electronApp) + expect(await getKittyKeyboardFlags(orcaPage)).toBe(31) + await select(null) + expect(await pressCmdC('Shift+Enter', '\x1b[13;2u')).toEqual(kittyCmdC) + + await sendToTerminal(orcaPage, ptyId, '\r') + await expect.poll(async () => await getKittyKeyboardFlags(orcaPage)).toBe(0) + expect(await pressCmdC('x', 'x')).toEqual([]) + await orcaPage.keyboard.press('Backspace') + } finally { + await electronApp.evaluate( + ({ clipboard }, text) => clipboard.writeText(text), + originalClipboard + ) + } + }) + test('@headful Codex-like background output stays visible without disabling WebGL in auto mode', async ({ orcaPage }) => { @@ -910,12 +898,13 @@ test.describe('Terminal Shortcuts', () => { orcaPage, electronApp }) => { + test.skip(process.platform === 'win32', 'ConPTY panes withhold the kitty protocol') await installMainProcessPtyWriteSpy(electronApp) // Why: CI can mount the xterm surface before the pane transport has a // live PTY. Probe first so xterm onData cannot race a disconnected // sendInput path, then clear the probe writes before the layout assertion. - await waitForActivePanePtyId(orcaPage) - await enableKittyKeyboardReporting(orcaPage, 31) + const ptyId = await waitForActivePanePtyId(orcaPage) + await armKittyKeyboardFromPty(orcaPage, ptyId, `printf '\\033[=31u'; cat`) await clearPtyWriteLog(electronApp) const dispatch = await pressShiftedRussianLayoutKey(orcaPage) @@ -937,5 +926,6 @@ test.describe('Terminal Shortcuts', () => { const joinedWrites = writes.join('') expect(joinedWrites).not.toContain('\x1b[97:1060;2;1060u') expect(joinedWrites).not.toContain('\x1b[97:1060;2:3u') + await sendToTerminal(orcaPage, ptyId, '\x03') }) }) diff --git a/tests/e2e/worktree-jump-sleep-focus.spec.ts b/tests/e2e/worktree-jump-sleep-focus.spec.ts new file mode 100644 index 00000000000..eacdea6b892 --- /dev/null +++ b/tests/e2e/worktree-jump-sleep-focus.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +test('Cmd+J focuses the terminal after waking a sleeping workspace', async ({ + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + const source = await waitForActiveWorktree(orcaPage) + const target = (await getAllWorktreeIds(orcaPage)).find((id) => id !== source) + if (!target) { + throw new Error('Expected a second seeded workspace') + } + await switchToWorktree(orcaPage, target) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + const targetName = await orcaPage.evaluate((id) => { + const state = window.__store!.getState() + const worktree = state.getKnownWorktreeById(id) + if (!worktree) { + throw new Error('Missing target workspace') + } + return worktree.branch.replace(/^refs\/heads\//, '') + }, target) + await switchToWorktree(orcaPage, source) + await orcaPage.evaluate(async (id) => { + await window.__store!.getState().shutdownWorktreeTerminals(id, { keepIdentifiers: true }) + }, target) + await orcaPage.evaluate(() => { + const listSessions = window.api.pty.listSessions + window.api.pty.listSessions = async (...args) => { + await new Promise((resolve) => setTimeout(resolve, 100)) + return listSessions(...args) + } + }) + // Native menu accelerators require OS focus; open the same palette in the hidden renderer. + await orcaPage.evaluate(() => window.__store!.getState().openModal('worktree-palette')) + const dialog = orcaPage.getByRole('dialog', { name: 'Jump to...' }) + await expect(dialog).toBeVisible() + await dialog.getByRole('combobox').fill(targetName) + await expect(dialog.locator('[cmdk-item][data-selected="true"]')).toContainText(targetName) + const cdp = await orcaPage.context().newCDPSession(orcaPage) + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 8 }) + await orcaPage.keyboard.press('Enter') + await expect(dialog).toBeHidden() + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + const terminal = orcaPage.locator('[data-terminal-tab-id]:visible .xterm-helper-textarea').first() + await orcaPage.screenshot({ path: testInfo.outputPath('wake-focus.png') }) + await expect(terminal).toBeFocused() + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }) + await cdp.detach() + await orcaPage.keyboard.type('echo WAKE_KEYBOARD_OK') + await orcaPage.keyboard.press('Enter') + await waitForTerminalOutput(orcaPage, 'WAKE_KEYBOARD_OK') + await orcaPage.screenshot({ path: testInfo.outputPath('wake-typing.png') }) +})