diff --git a/.github/workflows/macos-updater-tests.yml b/.github/workflows/macos-updater-tests.yml new file mode 100644 index 00000000000..056a40204b4 --- /dev/null +++ b/.github/workflows/macos-updater-tests.yml @@ -0,0 +1,49 @@ +name: macOS updater regression tests + +on: + pull_request: + paths: + - '.github/workflows/macos-updater-tests.yml' + - 'src/main/macos-update-running-instances*' + - 'src/main/updater*' + - 'src/main/updater/**' + - 'src/main/startup/main-process-quit*' + - 'src/main/window/main-window-state-lifecycle*' + - 'src/main/window/dashboard-popout-window*' + - 'src/shared/child-process/**' + - 'src/shared/update-status-types.ts' + - 'pnpm-lock.yaml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-updater-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + updater: + runs-on: macos-15 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Exercise native application registry and update shutdown + env: + ORCA_BACKGROUND_LAUNCH: '1' + run: >- + pnpm exec vitest run --config config/vitest.config.ts + src/main/macos-update-running-instances.test.ts + src/main/macos-update-running-instances.integration.test.ts + src/main/updater.mac-install.test.ts + src/main/updater.headless-serve-install.test.ts + src/main/updater-mac-quit-guard.test.ts + src/main/startup/desktop-startup-ordering.test.ts + src/main/startup/main-process-quit-update-veto.test.ts + src/main/window/main-window-state-lifecycle.test.ts + src/main/window/dashboard-popout-window.test.ts diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 0b121c5bb2e..90c3fc68473 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -164,7 +164,7 @@ jobs: cache-pnpm-store-lookup-only: 'true' # Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped # it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION - # moves past the Bun daemon's. Its build uses this checkout's installed dependencies. + # moves past the Bun daemon's. Install its pinned dependencies independently of this checkout. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 if: runner.os == 'Linux' with: @@ -179,7 +179,7 @@ jobs: if [ "$RUNNER_OS" != Linux ]; then exit 0; fi git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT" git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT" - ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules" + pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad" echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT" echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a90bd45d91e..b370dff9929 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -154,15 +154,17 @@ jobs: echo "No specs requiring the reusable E2E workflow" fi - static_analysis: - name: static analysis + preflight: + name: static analysis and typecheck needs: [code_paths] - if: needs.code_paths.outputs.static_analysis == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' # 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. + # the whole toolchain resolves. Free for public repositories. runs-on: ubuntu-24.04-arm + outputs: + shards: ${{ steps.unit-plan.outputs.shards }} steps: - name: Checkout @@ -197,21 +199,35 @@ jobs: # Keep each check in its own log while sharing this runner. - name: Lint + if: '!cancelled()' id: root-lint background: true - run: pnpm exec oxlint --format github + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm exec oxlint --format github - name: Reject low-evidence patterns + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run audit:anti-slop - wait: root-lint - name: Enforce focused code-quality plugins + if: '!cancelled()' id: native-code-quality background: true - run: pnpm run audit:code-quality:native + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run audit:code-quality:native - name: Enforce type-aware code-quality baseline + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run audit:code-quality:type-aware # Mobile installation changes import resolution for the native cycle check. @@ -221,28 +237,39 @@ jobs: # resolves types from mobile/node_modules. Without the install every mobile type # degrades to an `error` type — reported as phantom findings against the changed lines. - uses: ./.github/actions/install-mobile-dependencies - if: needs.code_paths.outputs.mobile_dependencies == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true' - name: Enforce changed-code quality + if: '!cancelled()' id: changed-code-quality background: true - run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" - name: Enforce React Doctor on changed lines + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}" - wait: changed-code-quality - name: Check Zustand selector fan-out budget + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:zustand-selector-fanout - name: Check reliability gate manifest + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:reliability-gates - name: Enforce dead design-system classes + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:dead-classes - name: Check VM runtime rollback compatibility + if: needs.code_paths.outputs.static_analysis == 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | @@ -264,25 +291,33 @@ jobs: config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts - name: Enforce max-lines ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:max-lines-ratchet - name: Enforce ts-nocheck ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:ts-nocheck-ratchet - name: Enforce runtime Electron-import ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:runtime-electron-ratchet - name: Check Node runtime pin + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:node-runtime-pin # Why: extraction writes sorted evidence to an isolated temporary path, # so feature PRs need one normalized AST pass rather than a three-OS matrix. - name: Verify localization extraction + if: '!cancelled()' id: localization-extraction background: true env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi # Detection failures run the full check; renames retain the removed input path. 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" && @@ -296,6 +331,7 @@ jobs: # which is a property of the import graph. This proves the Node artifact it enables # actually boots, pairs, creates a worktree and round-trips a real PTY. - name: Boot orcad and round-trip a terminal + if: needs.code_paths.outputs.static_analysis == 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} ORCA_BACKGROUND_LAUNCH: '1' @@ -310,20 +346,30 @@ jobs: fi - name: Verify the generated RPC params catalog + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:rpc-params-catalog - name: Verify bundled skill guides + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:bundled-skill-guides - name: Verify skill freshness manifest + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:skill-bundle-manifest - name: Verify localization catalogs + if: '!cancelled()' id: localization-catalogs background: true - run: pnpm run verify:localization-catalogs + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run verify:localization-catalogs - name: Verify localization coverage + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:localization-coverage - wait: [localization-catalogs, localization-extraction] @@ -334,6 +380,7 @@ jobs: # in .d.ts to `any`, which is how #1186 shipped a broken IPC signature # past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts. - name: Guard against project-owned .d.ts in preload/shared + if: needs.code_paths.outputs.static_analysis == 'true' run: | matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true) if [ -n "$matches" ]; then @@ -346,33 +393,19 @@ jobs: fi - name: Check feature wall asset budget + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm check:feature-wall-assets - name: Verify macOS entitlements + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm verify:macos-entitlements - typecheck: - needs: [code_paths] - if: needs.code_paths.outputs.typecheck == 'true' - # Typechecking uses no native runtime, so it can use the free public ARM runner. - runs-on: ubuntu-24.04-arm - outputs: - shards: ${{ steps.unit-plan.outputs.shards }} - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 2 - persist-credentials: false - - - uses: ./.github/actions/install-node-dependencies - # Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets # the next run skip unchanged files. Share one cache entry across commits while the # PR base stays stable; actions/cache keeps the first successful graph and the # compiler still invalidates stale files from its content hashes. - name: Cache TypeScript incremental state + if: needs.code_paths.outputs.typecheck == 'true' uses: actions/cache@v5 with: path: config/*.tsbuildinfo @@ -382,17 +415,24 @@ jobs: # Planning shares setup and stays off the compiler's critical path. - name: Plan unit selection + if: '!cancelled()' id: unit-plan background: true env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} - run: node config/scripts/ci-unit-plan.mjs + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + node config/scripts/ci-unit-plan.mjs - run: pnpm run typecheck + if: needs.code_paths.outputs.typecheck == 'true' - wait: unit-plan - uses: actions/upload-artifact@v7 + if: needs.code_paths.outputs.typecheck == 'true' with: name: unit-selection-attempt-${{ github.run_attempt }} path: ci-shards/unit-selection.json @@ -680,6 +720,7 @@ jobs: src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/fish-xdg-data-dirs-handoff.test.ts \ src/main/shell-startup-feature-channel.test.ts \ + src/main/zsh-deferred-startup-line-init.live-shell.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 \ @@ -691,20 +732,19 @@ jobs: src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts - # Static analysis saves the Node cache; typecheck publishes the plan before tests fan out. + # Preflight saves the Node cache and publishes the plan before tests fan out. test: - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] # Cancellation and failed prerequisites stop the expensive matrix. if: >- !cancelled() && needs.code_paths.outputs.test == 'true' && - needs.static_analysis.result == 'success' && - needs.typecheck.result == 'success' + needs.preflight.result == 'success' uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' runner: ubuntu-24.04-arm - shards: ${{ needs.typecheck.outputs.shards }} + shards: ${{ needs.preflight.outputs.shards }} # Why a sibling and not part of the test workflow: it is advisory, so it must not delay the # gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s @@ -902,7 +942,7 @@ jobs: package: name: package - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest # Let the serial Docker gates reach their own deadlines and report cleanup failures. @@ -1059,7 +1099,7 @@ jobs: package_windows: name: package (windows) - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] if: needs.code_paths.outputs.package_windows == 'true' runs-on: windows-2022 timeout-minutes: 30 @@ -1168,6 +1208,8 @@ jobs: src/main/runtime/unreadable-secret-store-preservation.win32.test.ts src/main/ipc/pty-codex-account-attribution.test.ts src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts + src/main/ipc/preflight-provider-command-selection.test.ts + src/main/ipc/preflight-runnable-local-cli.test.ts src/relay/windows-port-scan.win32.test.ts src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts src/main/ssh/remote-node-runtime-store-windows.test.ts @@ -1265,8 +1307,7 @@ jobs: if: ${{ !cancelled() }} needs: - code_paths - - static_analysis - - typecheck + - preflight - git_compatibility - codex_index_heal_contract - xterm_patch_sync @@ -1295,10 +1336,8 @@ jobs: env: CODE_PATHS: ${{ needs.code_paths.result }} SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} - STATIC_ANALYSIS: ${{ needs.static_analysis.result }} - STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} - TYPECHECK: ${{ needs.typecheck.result }} - TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} + PREFLIGHT: ${{ needs.preflight.result }} + PREFLIGHT_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }} CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }} @@ -1344,8 +1383,7 @@ jobs: fi } # Require success when the PR has code-relevant changes - check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN" - check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN" + check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN" check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN" check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN" check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN" diff --git a/README.md b/README.md index b9e441a3ab4..4c215157da9 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) · [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.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -235,7 +235,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) -- **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) +- **Android:** [Download APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk) --- diff --git a/cloud/apps/relay/src/host-control-proof-cleanup.test.ts b/cloud/apps/relay/src/host-control-proof-cleanup.test.ts new file mode 100644 index 00000000000..ce7a25fa530 --- /dev/null +++ b/cloud/apps/relay/src/host-control-proof-cleanup.test.ts @@ -0,0 +1,366 @@ +import { createHash, createHmac } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { + buildHostProofMacInput, + HostChallengeSchema, + HOST_CHALLENGE_PLAINTEXT_DOMAIN, + RELAY_CLOSE_CODE +} from '@orca-cloud/relay-contract' +import nacl from 'tweetnacl' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type WebSocket from 'ws' +import { RelayAssignmentStore } from './assignment-store.js' +import { loadRelayConfig } from './config.js' +import { RelayCredentialStore } from './credential-store.js' +import type { RelayDatabase } from './database.js' +import { HostSessionRegistry } from './host-session-registry.js' +import type { RelayTokenClaims } from './relay-token-verifier.js' +import { ProcessQueuedByteBudget } from './splice-forwarder.js' + +class ProofSocket extends EventEmitter { + readonly OPEN = 1 + readonly CLOSING = 2 + readonly CLOSED = 3 + readyState = this.OPEN + readonly send = vi.fn<(frame: string) => void>() + readonly close = vi.fn((code?: number, reason?: string) => { + this.readyState = this.CLOSED + this.emit('close', code, Buffer.from(reason ?? '')) + }) + + peerClose(): void { + this.readyState = this.CLOSED + this.emit('close', 1000, Buffer.alloc(0)) + } + + registrySocket(): WebSocket { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fake implements the registry's send, state, close and EventEmitter surface; no actual networking is invoked. + return this as unknown as WebSocket + } +} + +function fixture() { + const database: RelayDatabase = { + query: vi.fn(async () => []), + queryLocked: vi.fn(async () => []), + transaction: (operation) => operation(database), + close: async () => undefined + } + const config = loadRelayConfig({ + ORCA_RELAY_PUBLIC_URL: 'http://127.0.0.1', + ORCA_RELAY_CELL_URL: 'http://127.0.0.1', + ORCA_RELAY_AUTH_ISSUER: 'https://auth.example.test', + ORCA_RELAY_JWKS_URL: 'https://auth.example.test/jwks', + ORCA_RELAY_ASSIGNMENT_SIGNING_KEY: 'synthetic-assignment-key-for-test-only', + ORCA_RELAY_ROLE: 'cell', + ORCA_RELAY_ADMIN_AUDIENCE: 'https://auth.example.test/admin', + ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'deploy@example.test', + ORCA_RELAY_CELL_CONNECTION_HARD_CAP: '600', + ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND: '60' + }) + const assignments = new RelayAssignmentStore(database) + const verify = vi.spyOn(assignments, 'verifyCellAssignment').mockResolvedValue(true) + const activate = vi.spyOn(assignments, 'activateControl').mockResolvedValue('control:1') + vi.spyOn(assignments, 'markMigrationTargetRegistered').mockResolvedValue(true) + const recordAuth = vi.fn() + const registry = new HostSessionRegistry( + config, + async () => null, + new RelayCredentialStore(database), + assignments, + new ProcessQueuedByteBudget(), + { + recordAuth, + recordForwardedBytes: vi.fn(), + recordHttp: vi.fn(), + recordReconnect: vi.fn(), + recordSql: vi.fn() + } + ) + const keyPair = nacl.box.keyPair() + const identity = { + sub: 'user-proof', + prof: 'profile-proof', + relayHostId: createHash('sha256').update(keyPair.publicKey).digest('base64url').slice(0, 16), + purpose: 'host-control', + exp: Math.floor(Date.now() / 1000) + 3600 + } satisfies RelayTokenClaims + const hello = JSON.stringify({ + type: 'host-hello', + v: 1, + relayHostId: identity.relayHostId, + assignmentEpoch: 1, + hostPublicKeyB64: Buffer.from(keyPair.publicKey).toString('base64'), + appVersion: 'test' + }) + return { registry, verify, activate, recordAuth, database, identity, keyPair, hello } +} + +async function openProof(h: ReturnType, socket = new ProofSocket()) { + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from(h.hello), false) + await vi.advanceTimersByTimeAsync(0) + expect(h.verify).toHaveBeenCalled() + expect(socket.send).toHaveBeenCalledOnce() + return socket +} + +function answerProof(socket: ProofSocket, keyPair: nacl.BoxKeyPair): void { + const frame = socket.send.mock.calls[0]?.[0] + if (frame === undefined) { + throw new Error('missing challenge') + } + const parsed: unknown = JSON.parse(frame) + if (parsed === null || typeof parsed !== 'object' || !('type' in parsed)) { + throw new Error('invalid challenge frame') + } + const { type, ...fields } = parsed + expect(type).toBe('host-challenge') + const challenge = HostChallengeSchema.parse(fields) + const plaintext = nacl.box.open( + Buffer.from(challenge.ciphertextB64, 'base64'), + Buffer.from(challenge.nonceB64, 'base64'), + Buffer.from(challenge.relayEphemeralPublicKeyB64, 'base64'), + keyPair.secretKey + ) + if (plaintext === null) { + throw new Error('challenge did not decrypt') + } + const domain = new TextEncoder().encode(`${HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`) + expect(plaintext.subarray(0, domain.length)).toEqual(domain) + const transcriptLength = new DataView( + plaintext.buffer, + plaintext.byteOffset + domain.length, + 4 + ).getUint32(0, false) + const transcriptStart = domain.length + 4 + const transcript = plaintext.subarray(transcriptStart, transcriptStart + transcriptLength) + const secret = plaintext.subarray(transcriptStart + transcriptLength) + const proofB64 = createHmac('sha256', secret) + .update(buildHostProofMacInput(transcript)) + .digest('base64') + socket.emit( + 'message', + Buffer.from( + JSON.stringify({ type: 'host-challenge-ack', challengeId: challenge.challengeId, proofB64 }) + ), + false + ) +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('host control proof cleanup', () => { + it('allocates no hello stage for an already closed peer', () => { + const h = fixture() + const socket = new ProofSocket() + socket.peerClose() + h.registry.acceptControl(socket.registrySocket(), h.identity) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('message')).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.verify).not.toHaveBeenCalled() + }) + + it('releases the host hello timer and listeners when its peer closes early', () => { + const h = fixture() + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + expect(vi.getTimerCount()).toBe(1) + expect(socket.listenerCount('message')).toBe(1) + socket.peerClose() + expect({ + timers: vi.getTimerCount(), + message: socket.listenerCount('message'), + close: socket.listenerCount('close') + }).toEqual({ timers: 0, message: 0, close: 0 }) + vi.advanceTimersByTime(2000) + expect(socket.close).not.toHaveBeenCalled() + expect(h.verify).not.toHaveBeenCalled() + expect(h.database.query).not.toHaveBeenCalled() + }) + + it('preserves the exact hello deadline and refusal while releasing its message listener', () => { + const h = fixture() + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + vi.advanceTimersByTime(1999) + expect(socket.close).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'host hello timeout' + ) + expect(socket.listenerCount('message')).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('releases the challenge timer and listeners when its peer closes before proof', async () => { + const h = fixture() + const socket = await openProof(h) + expect(vi.getTimerCount()).toBe(1) + expect(socket.listenerCount('message')).toBe(1) + socket.peerClose() + expect({ + timers: vi.getTimerCount(), + message: socket.listenerCount('message'), + close: socket.listenerCount('close') + }).toEqual({ timers: 0, message: 0, close: 0 }) + await vi.advanceTimersByTimeAsync(10_000) + expect(socket.close).not.toHaveBeenCalled() + expect(h.activate).not.toHaveBeenCalled() + expect(h.database.query).not.toHaveBeenCalled() + }) + + it('preserves the exact proof deadline and refusal with no leftover listener', async () => { + const h = fixture() + const socket = await openProof(h) + await vi.advanceTimersByTimeAsync(9999) + expect(socket.close).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'host proof timeout' + ) + expect(socket.listenerCount('message')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + expect(h.recordAuth).not.toHaveBeenCalled() + }) + + it('does no challenge crypto, send, timer or registration after a closed peer finishes verification', async () => { + const h = fixture() + let finish!: (valid: boolean) => void + h.verify.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve + }) + ) + const generateKey = vi.spyOn(nacl.box, 'keyPair') + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from(h.hello), false) + expect(h.verify).toHaveBeenCalledOnce() + socket.peerClose() + finish(true) + await vi.advanceTimersByTimeAsync(0) + expect(socket.send).not.toHaveBeenCalled() + expect(generateKey).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('message')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + expect(h.database.query).not.toHaveBeenCalled() + expect( + h.registry.get({ userId: h.identity.sub, relayHostId: h.identity.relayHostId }) + ).toBeNull() + }) + + it.each([false, true])('preserves invalid first-frame refusal (binary=%s)', (binary) => { + const h = fixture() + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from('{}'), binary) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + binary ? 'host hello must be text' : 'invalid host hello' + ) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + }) + + it.each([false, true])( + 'preserves invalid proof authentication failure (binary=%s)', + async (binary) => { + const h = fixture() + const socket = await openProof(h) + socket.emit('message', Buffer.from('{}'), binary) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, + 'invalid host proof' + ) + expect(h.recordAuth).toHaveBeenCalledExactlyOnceWith(false) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + } + ) + + it('allocates no proof wait when sending the challenge closes its peer', async () => { + const h = fixture() + const socket = new ProofSocket() + socket.send.mockImplementation(() => socket.peerClose()) + await openProof(h, socket) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('message')).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + expect(h.activate).not.toHaveBeenCalled() + }) + + it('keeps the existing diagnostic and refusal when challenge send throws', async () => { + const h = fixture() + const socket = new ProofSocket() + socket.send.mockImplementation(() => { + throw new Error('synthetic send failure') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await openProof(h, socket) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.LIMIT_EXCEEDED, + 'relay temporarily unavailable' + ) + expect(warn).toHaveBeenCalledExactlyOnceWith( + '[orca-relay] host hello proof failed: synthetic send failure' + ) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + }) + + it('contains assignment lookup rejection with its existing close and diagnostic', async () => { + const h = fixture() + h.verify.mockRejectedValueOnce(new Error('synthetic lookup failure')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const socket = new ProofSocket() + h.registry.acceptControl(socket.registrySocket(), h.identity) + socket.emit('message', Buffer.from(h.hello), false) + await vi.advanceTimersByTimeAsync(0) + expect(socket.close).toHaveBeenCalledExactlyOnceWith( + RELAY_CLOSE_CODE.LIMIT_EXCEEDED, + 'relay temporarily unavailable' + ) + expect(warn).toHaveBeenCalledExactlyOnceWith( + '[orca-relay] host hello proof failed: synthetic lookup failure' + ) + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount('close')).toBe(0) + }) + + it('keeps a newer same-host peer live when the old proof peer closes', async () => { + const h = fixture() + const oldPeer = await openProof(h) + const replacement = await openProof(h) + oldPeer.peerClose() + expect(vi.getTimerCount()).toBe(1) + answerProof(replacement, h.keyPair) + await vi.advanceTimersByTimeAsync(0) + expect(h.activate).toHaveBeenCalledOnce() + expect(h.recordAuth).toHaveBeenCalledExactlyOnceWith(true) + expect( + h.registry.get({ userId: h.identity.sub, relayHostId: h.identity.relayHostId })?.socket + ).toBe(replacement) + expect(replacement.send).toHaveBeenCalledTimes(2) + expect(replacement.listenerCount('message')).toBe(1) + expect(replacement.listenerCount('close')).toBe(2) + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(10_000) + expect(oldPeer.close).not.toHaveBeenCalled() + expect(replacement.close).not.toHaveBeenCalled() + h.registry.drain(0) + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 4e3372ce692..06cfb2d57ac 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -160,6 +160,30 @@ function send(socket: WebSocket, type: string, message: object): void { socket.send(JSON.stringify({ type, ...message })) } +function readControlFrame( + socket: WebSocket, + timeoutMs: number, + timeoutReason: string, + receive: (raw: RawData, isBinary: boolean) => void +): void { + if (socket.readyState !== socket.OPEN) return + const timer = setTimeout(() => { + finish() + socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, timeoutReason) + }, timeoutMs) + function finish(): void { + clearTimeout(timer) + socket.off('message', onMessage) + socket.off('close', finish) + } + function onMessage(raw: RawData, isBinary: boolean): void { + finish() + receive(raw, isBinary) + } + socket.once('message', onMessage) + socket.once('close', finish) +} + // Hosts abandon connects after 15s; waiting much longer than that behind a // stalled predecessor only accumulates doomed sockets. const ACTIVATION_QUEUE_WAIT_MS = 30_000 @@ -794,12 +818,7 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.DRAINING, 'relay draining') return } - let firstFrameTimer: ReturnType | null = setTimeout(() => { - socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host hello timeout') - }, 2_000) - socket.once('message', (raw, isBinary) => { - if (firstFrameTimer) clearTimeout(firstFrameTimer) - firstFrameTimer = null + readControlFrame(socket, 2_000, 'host hello timeout', (raw, isBinary) => { if (isBinary) { socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host hello must be text') return @@ -991,6 +1010,7 @@ export class HostSessionRegistry { socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'wrong assignment epoch') return } + if (socket.readyState !== socket.OPEN) return const key = this.key(identity.sub, identity.relayHostId) const existing = this.sessions.get(key) @@ -1035,11 +1055,7 @@ export class HostSessionRegistry { ciphertextB64: Buffer.from(ciphertext).toString('base64'), expiresAt }) - const proofTimer = setTimeout(() => { - socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host proof timeout') - }, 10_000) - socket.once('message', (raw, isBinary) => { - clearTimeout(proofTimer) + readControlFrame(socket, 10_000, 'host proof timeout', (raw, isBinary) => { const ack = isBinary ? null : HostChallengeAckSchema.safeParse(payload(raw, 'host-challenge-ack')) diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 5ea771e293c..0640a984437 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -122,10 +122,15 @@ function assertNoElectronRequire( entryName: string, entry: OutputChunk, byFileName: Map, + electronFreeChunkCode: Map, runtime: EntryRuntime = 'plain-Node process' ): void { for (const chunk of collectReachableChunks(entry, byFileName)) { - if (ELECTRON_REQUIRE_RE.test(chunk.code)) { + const code = chunk.code + if (electronFreeChunkCode.get(chunk) === code) { + continue + } + if (ELECTRON_REQUIRE_RE.test(code)) { throw new Error( `[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` + `requires electron. "${entryName}" runs as a ${runtime}, where ` + @@ -133,6 +138,7 @@ function assertNoElectronRequire( `v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.` ) } + electronFreeChunkCode.set(chunk, code) } } @@ -272,17 +278,30 @@ export function createPlainNodeEntryGuardPlugin( } } + const electronFreeChunkCode = new Map() for (const entryName of PLAIN_NODE_ENTRY_NAMES) { const entry = entryByName.get(entryName) if (entry) { - assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process') + assertNoElectronRequire( + entryName, + entry, + byFileName, + electronFreeChunkCode, + 'plain-Node process' + ) } } for (const entryName of WORKER_THREAD_ENTRY_NAMES) { const entry = entryByName.get(entryName) if (entry) { - assertNoElectronRequire(entryName, entry, byFileName, 'worker thread') + assertNoElectronRequire( + entryName, + entry, + byFileName, + electronFreeChunkCode, + 'worker thread' + ) } } diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index c88d70350eb..9b1418cf28c 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -25,8 +25,6 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ 'node-pty', 'posthog-node', 'proper-lockfile', - // serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too. - 'serve-sim', 'qrcode', 'ssh2', 'tweetnacl', @@ -34,6 +32,9 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ 'yaml', 'zod' ] +// Why macOS only: serve-sim drives the iOS Simulator, and its native addon is a Mach-O that +// Windows signing rejects as a PE file. +const DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['serve-sim'] const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [ '@vscode/windows-process-tree', '@orca/windows-registry' @@ -180,6 +181,7 @@ function collectPackagedRuntimePackages(electronPlatformName = process.platform) // Why: cross-builds must select native dependencies from the artifact target, not the build host. const packageRoots = [ ...PACKAGED_RUNTIME_PACKAGE_ROOTS, + ...(electronPlatformName === 'darwin' ? DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS : []), ...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : []) ] for (const packageName of packageRoots) { diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index a157e9dc79c..faf844b5137 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -21411,6 +21411,90 @@ "A tombstone that exhausts its retries stays on disk until the next startup, unchanged from before." ], "demotionRule": "Keep experimental or demote if the reused listing strands a displaced root, crosses the admission cap, rearms an exhausted retry through another root, hands one tombstone to removal twice, or touches a recreated live history path." + }, + { + "id": "terminal-performance.consumed-side-effect-retention", + "title": "Terminal side-effect queues release successfully applied and evicted effects", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-unit", + "surfaces": ["terminal output side effects", "renderer memory census"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "Provider-independent queue and mocked IPC output contracts run on macOS. Remote-runtime uses this processor but has no live session run. Native mobile uses another processor and is unaffected; host ownership, ACKs, wire, paths, folder/git identity, PTY lifecycle and output bytes are unchanged. Linux, Windows, WSL and live remote execution are gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/blob/main/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts" + ], + "invariant": "Release consumed title/payload objects after successful apply or overflow carry, preserving callback order, 64-effect drains, the 512-effect pending cap, bell and payload carry, empty-tail coalescing, reentrant clear/flush/enqueue, thrown-apply behavior and output delivery.", + "oracle": "Forced GC collects all 64 applied effects from a 100-effect bounded drain while the remaining 36 stay alive and deliver in order; it also collects the first evicted effect in a 513-effect burst while all 512 survivors remain alive. Clearing during apply immediately releases all 99 other pending effects, as the original queue did. Census reports 36 retained objects after the bounded drain. Explicit reentrant and error cases produce the same observations against the original queue.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts", + "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts", + "src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts", + "assertions": [ + "releases applied effects while preserving every pending effect and its delivery order", + "releases an evicted effect before the compaction threshold", + "releases every pending effect immediately when clear is called during apply" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts", + "assertions": [ + "keeps empty-tail coalescing observable during the apply callback", + "delivers the same effect requeued after clear without releasing its new slot", + "preserves nested flush order and effects enqueued after the inner compaction", + "preserves thrown apply errors and their existing empty-tail coalescing" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-10-01", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-retention.test.ts src/renderer/src/components/terminal-pane/pty-output-side-effect-queue-reentrancy.test.ts src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts src/renderer/src/components/terminal-pane/pty-transport-output-side-effects.test.ts src/renderer/src/components/terminal-pane/pty-transport-eager-buffer-replay.test.ts", + "result": "passed", + "durationSeconds": 1.66, + "summary": "46 tests across five files passed. Four reentrant/error cases also pass against an isolated original-queue copy; stock and candidate both release 99 pending effects when clear runs during apply." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused renderer queue/output tests, including forced GC; p95 not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local author and independent review validation; no CI soak. Reference-lifetime tests require the test runner's existing --expose-gc." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Original queue fails both WeakRef collection assertions: all 64 applied effects and the evicted effect remain reachable. Clearing only consumed slots passes while all pending effects stay live. Baseline and candidate both pass all four reentrant/error observations." + }, + "performanceBudget": { + "required": true, + "evidence": "Retained objects fall from 100 to 36 after one bounded drain and from 513 to 512 after one overflow eviction before compaction. Release is constant work per consumed effect; no changed drain limit, timer, polling, batching, cache, transport call or subprocess. Existing compaction cadence remains; clear truncates then replaces its backing array to preserve immediate release and protect reentrant same-object requeue." + }, + "knownGaps": [ + "No real renderer heap-byte or input-latency measurement; references and complete delivery are the deterministic oracle.", + "No live Linux, Windows, WSL, SSH or paired-runtime session run; these use provider-independent queue code.", + "Thrown apply callbacks keep their consumed reference until the original compaction boundary to preserve exception/coalescing behavior." + ], + "promotionCriteria": [ + "Collect CI soak with zero unexplained GC flakes and retain callback-order, overflow-carry and reentrancy assertions." + ], + "demotionRule": "Keep experimental; investigate delivery, coalescing, error-path or pending-reference regressions without weakening the retention or fidelity oracle." } ] } diff --git a/config/scripts/check-node-runtime-pin.test.mjs b/config/scripts/check-node-runtime-pin.test.mjs index e1be15f0f7f..a805985d966 100644 --- a/config/scripts/check-node-runtime-pin.test.mjs +++ b/config/scripts/check-node-runtime-pin.test.mjs @@ -189,7 +189,7 @@ describe('committed pin', () => { it('runs in the static analysis job', () => { const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) - const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '') + const commands = workflow.jobs.preflight.steps.map((step) => step.run ?? '') expect(commands).toContain('pnpm run check:node-runtime-pin') }) }) diff --git a/config/scripts/check-readme-local-links.test.mjs b/config/scripts/check-readme-local-links.test.mjs index e1d69723ee5..c9128630f19 100644 --- a/config/scripts/check-readme-local-links.test.mjs +++ b/config/scripts/check-readme-local-links.test.mjs @@ -156,8 +156,7 @@ describe('README local link check', () => { ]) }) - // Why the ungated job: static_analysis is skipped for docs-only diffs, which is - // exactly the kind of PR that deletes a docs-site GIF the README embeds. + // Docs-only diffs skip preflight, so the detector must check README links. it('runs on every PR through the ungated detector and in the lint script', () => { const { scripts } = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8')) const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) diff --git a/config/scripts/ci-background-step-barriers.test.mjs b/config/scripts/ci-background-step-barriers.test.mjs index 329e6c0bbce..e005c60b989 100644 --- a/config/scripts/ci-background-step-barriers.test.mjs +++ b/config/scripts/ci-background-step-barriers.test.mjs @@ -20,8 +20,7 @@ function assertJoinedBefore(steps, id, consumer) { describe('CI background step barriers', () => { it('joins every background check without suppressing failures', () => { for (const job of [ - pr.jobs.static_analysis, - pr.jobs.typecheck, + pr.jobs.preflight, pr.jobs.mobile_web_app, pr.jobs.package, pr.jobs.shell_contracts, @@ -52,7 +51,7 @@ describe('CI background step barriers', () => { it('joins planning before publishing the unit artifact', () => { assertJoinedBefore( - pr.jobs.typecheck.steps, + pr.jobs.preflight.steps, 'unit-plan', (step) => step.uses === 'actions/upload-artifact@v7' ) @@ -88,7 +87,7 @@ describe('CI background step barriers', () => { }) it('finishes native import-cycle analysis before mobile installation changes resolution', () => { - const steps = pr.jobs.static_analysis.steps + const steps = pr.jobs.preflight.steps assertJoinedBefore(steps, 'native-code-quality', (step) => step.uses?.endsWith('/install-mobile-dependencies') ) diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 469800edfac..01b6c214e5d 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -28,7 +28,7 @@ it('warms the same Linux Node runtime the PR shards restore', () => { const install = arm.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const primer = readWorkflow('pr').jobs.static_analysis + const primer = readWorkflow('pr').jobs.preflight 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).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with) @@ -51,7 +51,7 @@ it('populates shared Electron archives on both Linux architectures without chang it('publishes incremental state under a key and prefix that new PRs restore', () => { const cache = steps.find((step) => step.id === 'typecheck-cache') - const prCache = readWorkflow('pr').jobs.typecheck.steps.find((step) => step.name === cache.name) + const prCache = readWorkflow('pr').jobs.preflight.steps.find((step) => step.name === cache.name) expect(cache.with.path).toBe(prCache.with.path) expect(cache.with['restore-keys']).toBe(prCache.with['restore-keys']) expect(cache.with.key).toBe( diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 80252973e22..3e0cdb67cd8 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -23,6 +23,7 @@ export const UNIT_EXCLUDE = [ '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/zsh-deferred-startup-line-init.live-shell.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', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 6e47f780a76..454693e46d7 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -242,6 +242,23 @@ describe('electron-builder config', () => { ]) }) + // Why: serve-sim's addon is a Mach-O, and Windows signing rejects every *.node that is not PE. + it('keeps serve-sim out of the Windows and Linux runtime closures', () => { + const { + PACKAGED_RUNTIME_PACKAGE_ROOTS, + createPackagedRuntimeNodeModuleResources + } = require('../packaged-runtime-node-modules.cjs') + expect(PACKAGED_RUNTIME_PACKAGE_ROOTS).not.toContain('serve-sim') + const serveSimTarget = join('node_modules', 'serve-sim') + expect(createPackagedRuntimeNodeModuleResources('linux').map((r) => r.to)).not.toContain( + serveSimTarget + ) + expect(electronBuilderConfig.linux.extraResources.map((r) => r.to)).not.toContain( + serveSimTarget + ) + expect(electronBuilderConfig.win.extraResources.map((r) => r.to)).not.toContain(serveSimTarget) + }) + // Why: the Windows CLI shim is delivered only via extraResources to // resources/bin/orca.cmd (beside the native resources/bin/orca.exe). If the // source tree is also packed into app.asar it gets extracted by diff --git a/config/scripts/github-opened-issue-repository.test.ts b/config/scripts/github-opened-issue-repository.test.ts new file mode 100644 index 00000000000..b2d0519c5cc --- /dev/null +++ b/config/scripts/github-opened-issue-repository.test.ts @@ -0,0 +1,440 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import type * as ReactModule from 'react' +import type * as GhUtils from '../../src/main/github/gh-utils' +import type * as IssueMetadata from '../../src/renderer/src/hooks/useIssueMetadata' +import type { GitHubWorkItem } from '../../src/shared/github/work-item-types' +import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types' +import type { TaskSourceContext } from '../../src/shared/task-source-context' +import type { Repo } from '../../src/shared/repo-types' + +const fixture = vi.hoisted(() => { + const state: { + loads: { key: string | null; load: () => Promise }[] + requests: { args: string[]; host?: string }[] + gh: ReturnType + apiUpdate: ReturnType + preference: 'origin' | 'upstream' + } = { loads: [], requests: [], gh: vi.fn(), apiUpdate: vi.fn(), preference: 'upstream' } + return state +}) + +vi.mock('react', async (original) => ({ + ...(await original()), + useState: (initial: unknown) => [typeof initial === 'function' ? initial() : initial, vi.fn()], + useMemo: (value: () => T) => value(), + useCallback: (value: T) => value, + useRef: (initial: T) => ({ current: initial }), + useEffect: vi.fn() +})) +vi.mock('zustand/react/shallow', () => ({ useShallow: (value: T) => value })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ patchWorkItem: vi.fn(), patchProjectRowContent: vi.fn() }), + { getState: () => ({ recordFeatureInteraction: vi.fn() }) } + ) +})) +vi.mock('@/lib/repo-runtime-owner', () => ({ + getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null }) +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({ + useGitHubDuplicateIssueCandidates: () => [] +})) +vi.mock('@/components/github/github-work-item-comment-mutations', () => ({ + notifyWorkItemDetailsMutation: vi.fn() +})) +vi.mock('@/hooks/useIssueMetadata', async (original) => ({ + ...(await original()), + useImmediateMutation: () => ({ isPending: () => false, run: vi.fn() }) +})) +vi.mock('@/hooks/useMetadataListRequest', () => ({ + useMetadataListRequest: (args: { cacheKey: string | null; load: () => Promise }) => { + fixture.loads.push({ key: args.cacheKey, load: args.load }) + return { data: [], loading: false, error: null } + } +})) +vi.mock('../../src/main/github/gh-utils', async (original) => ({ + ...(await original()), + ghExecFileAsync: fixture.gh, + acquire: vi.fn(), + release: vi.fn(), + getOwnerRepoForRemote: async (_path: string, remote: string) => ({ + owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner', + repo: 'widgets' + }) +})) +vi.mock('../../src/main/git/remote-name-listing', () => ({ + shouldProbeGitRemote: async () => true +})) + +import { GHEditSection } from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section' +import { + runGHEditLabelToggle, + runGHEditStateChange +} from '../../src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations' +import { findTaskPageDialogWorkItem } from '../../src/renderer/src/components/task-page-cache-selectors' +import { getTaskPageRepoSourceContext } from '../../src/renderer/src/components/task-page-source-context' +import { workItemsCacheKey } from '../../src/renderer/src/store/github/cache-identity' +import { createTestStore } from '../../src/renderer/src/store/slices/github-slice-test-harness' +import { getTaskSourceCacheScope } from '../../src/shared/task-source-context' +import { listLabels, listAssignableUsers } from '../../src/main/github/issue-field-options' +import { useRepoLabels, useRepoAssignees } from '../../src/renderer/src/hooks/useIssueMetadata' +import { updateIssue } from '../../src/main/github/issue-update' +import { materializeTaskPageItemList } from '../../src/renderer/src/components/task-page-github-work-item-mutations' +import { + resetTaskPageGitHubMutationRegistryForTests, + setTaskPageGitHubMutationQueryKey +} from '../../src/renderer/src/components/task-page-github-work-item-mutation-registry' + +function renderEditSection(props: Parameters[0]): void { + renderToStaticMarkup(createElement(GHEditSection, props)) +} + +const registeredRepo: Repo = { + id: 'repo-1', + path: join(tmpdir(), 'orca-opened-issue-repository-fixture'), + displayName: 'widgets', + badgeColor: 'primary', + addedAt: 1, + upstream: { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' } +} + +function sourceFor(preference: 'origin' | 'upstream'): TaskSourceContext { + const source = getTaskPageRepoSourceContext( + { ...registeredRepo, issueSourcePreference: preference }, + 'github' + ) + if (!source) { + throw new Error('Registered fixture must produce a source context') + } + return source +} + +const sourceContext = sourceFor('origin') +const fork: GitHubWorkItem = { + id: 'issue:5', + type: 'issue', + number: 5, + title: 'FORK title', + state: 'open', + url: 'https://github.com/fork-owner/widgets/issues/5', + labels: [], + updatedAt: '', + author: null, + repoId: 'repo-1' +} +const upstream: GitHubWorkItem = { + ...fork, + title: 'UPSTREAM title', + url: 'https://github.com/upstream-owner/widgets/issues/5' +} +const issueRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } + +type MetadataArgs = { repoPath: string; ownerRepo?: GitHubOwnerRepo } + +beforeEach(() => { + fixture.preference = 'upstream' + fixture.loads = [] + fixture.requests = [] + resetTaskPageGitHubMutationRegistryForTests() + setTaskPageGitHubMutationQueryKey('current-upstream-list') + fixture.gh.mockReset() + fixture.gh.mockImplementation(async (args: string[], options: { host?: string }) => { + fixture.requests.push({ args, host: options.host }) + return { stdout: '', stderr: '' } + }) + fixture.apiUpdate = vi.fn((args: Parameters[0]) => + updateIssue( + args.repoPath, + args.number, + args.updates, + null, + {}, + fixture.preference, + args.ownerRepo + ) + ) + vi.stubGlobal('window', { + api: { + gh: { + updateIssue: fixture.apiUpdate, + listLabels: (args: MetadataArgs) => + listLabels(args.repoPath, fixture.preference, null, {}, args.ownerRepo), + listAssignableUsers: (args: MetadataArgs) => + listAssignableUsers(args.repoPath, fixture.preference, null, {}, args.ownerRepo) + } + } + }) +}) + +afterEach(() => { + resetTaskPageGitHubMutationRegistryForTests() + vi.unstubAllGlobals() +}) + +it.each([ + { openedItem: fork, listItem: upstream, preference: 'upstream' }, + { openedItem: upstream, listItem: fork, preference: 'origin' }, + { openedItem: fork, listItem: fork, preference: 'origin' } +] as const)( + 'scopes $openedItem.title labels under $preference to its canonical list row', + async ({ openedItem, listItem, preference }) => { + fixture.preference = preference + expect(sourceFor('upstream')).toEqual(sourceContext) + const store = createTestStore() + const key = workItemsCacheKey( + registeredRepo.id, + 36, + '', + getTaskSourceCacheScope(sourceFor('upstream')) + ) + store.setState({ + workItemsCache: { [key]: { data: [listItem], fetchedAt: Date.now() } } + }) + const opened = + findTaskPageDialogWorkItem(store.getState().workItemsCache, { + id: openedItem.id, + repoId: openedItem.repoId, + url: openedItem.url + }) ?? openedItem + expect(opened.url).toBe(openedItem.url) + const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' } + let mutation: Promise = Promise.resolve() + runGHEditLabelToggle({ + itemId: opened.id, + itemNumber: opened.number, + itemRepoId: opened.repoId, + repoPath: registeredRepo.path, + sourceContext, + projectOrigin: undefined, + issueRepo: target, + label: 'fork-only-label', + localLabels: [], + run: async (_key, options) => { + options.onOptimistic?.() + mutation = options.mutate() + await mutation + options.onSuccess?.() + return true + }, + onLabelsChange: vi.fn(), + patchWorkItem: store.getState().patchWorkItem, + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + await mutation + expect(fixture.requests[0].args).toContain(`${target.owner}/widgets`) + expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target })) + expect(store.getState().workItemsCache[key]?.data?.[0].labels).toEqual( + listItem.url === openedItem.url ? ['fork-only-label'] : [] + ) + } +) + +it.each([ + { openedItem: fork, owner: 'fork-owner', preference: 'origin' }, + { openedItem: fork, owner: 'fork-owner', preference: 'upstream' }, + { openedItem: upstream, owner: 'upstream-owner', preference: 'origin' }, + { openedItem: upstream, owner: 'upstream-owner', preference: 'upstream' } +] as const)( + 'loads $owner picker candidates while preference=$preference', + async ({ preference, openedItem, owner }) => { + fixture.preference = preference + renderEditSection({ + item: openedItem, + repoPath: registeredRepo.path, + repoId: fork.repoId, + sourceContext, + projectOrigin: undefined, + localState: 'open', + localLabels: [], + assignees: [], + onStateChange: vi.fn(), + onLabelsChange: vi.fn(), + onMutated: vi.fn(), + onUse: vi.fn() + }) + for (const request of fixture.loads.filter((load) => load.key !== null)) { + await request.load() + } + expect( + fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/'))) + ).toEqual([`repos/${owner}/widgets/labels`, `repos/${owner}/widgets/assignees?per_page=100`]) + } +) + +it.each([ + { openedItem: fork, listItem: fork }, + { openedItem: fork, listItem: upstream }, + { openedItem: upstream, listItem: fork }, + { openedItem: upstream, listItem: upstream } +])( + 'a $openedItem.title close only controls its own row while search lags (list=$listItem.title)', + async ({ openedItem, listItem }) => { + const target = { ...issueRepo, owner: openedItem === fork ? 'fork-owner' : 'upstream-owner' } + let pending = Promise.resolve() + runGHEditStateChange({ + newState: 'closed', + localState: 'open', + itemId: fork.id, + itemNumber: fork.number, + itemRepoId: fork.repoId, + repoPath: registeredRepo.path, + sourceContext, + projectOrigin: undefined, + issueRepo: target, + run: (_key, options) => { + pending = (async () => { + options.onOptimistic?.() + await options.mutate() + options.onSuccess?.() + })() + return pending + }, + onStateChange: vi.fn(), + patchWorkItem: vi.fn(), + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + await pending + expect(fixture.apiUpdate).toHaveBeenCalledWith(expect.objectContaining({ ownerRepo: target })) + const displayed = materializeTaskPageItemList({ + networkItems: [listItem], + previousItems: [listItem], + queryKey: 'current-upstream-list' + }) + expect(displayed[0]?.state).toBe(listItem.url === openedItem.url ? 'closed' : 'open') + } +) + +it('keeps ordinary metadata caches distinct by canonical repository and host', () => { + const identities = [ + issueRepo, + { ...issueRepo, owner: 'upstream-owner' }, + { ...issueRepo, host: 'ghe.example:8443' } + ] + for (const ownerRepo of identities) { + useRepoLabels(registeredRepo.path, registeredRepo.id, { ownerRepo }) + useRepoAssignees(registeredRepo.path, registeredRepo.id, { ownerRepo }) + } + expect(new Set(fixture.loads.filter((_, i) => i % 2 === 0).map((load) => load.key)).size).toBe(3) + expect(new Set(fixture.loads.filter((_, i) => i % 2 === 1).map((load) => load.key)).size).toBe(3) +}) + +it('keeps metadata requests without an explicit target compatible', async () => { + useRepoLabels(registeredRepo.path, registeredRepo.id) + useRepoAssignees(registeredRepo.path, registeredRepo.id) + for (const request of fixture.loads) { + await request.load() + } + expect(fixture.loads.map((load) => load.key)).toEqual([registeredRepo.id, registeredRepo.id]) + expect( + fixture.requests.map((request) => request.args.find((arg) => arg.startsWith('repos/'))) + ).toEqual([ + 'repos/upstream-owner/widgets/labels', + 'repos/upstream-owner/widgets/assignees?per_page=100' + ]) +}) + +it('keeps Project row metadata on the existing slug route', async () => { + const labels = vi.fn().mockResolvedValue({ ok: true, labels: [] }) + const users = vi.fn().mockResolvedValue({ ok: true, users: [] }) + vi.stubGlobal('window', { + api: { gh: { listLabelsBySlug: labels, listAssignableUsersBySlug: users } } + }) + renderEditSection({ + item: fork, + repoPath: registeredRepo.path, + repoId: fork.repoId, + sourceContext, + projectOrigin: { + owner: 'project-owner', + repo: 'outside', + host: 'ghe.example', + number: fork.number, + type: 'issue', + projectId: 'project-1', + projectItemId: 'row-1', + cacheKey: 'project-key' + }, + localState: 'open', + localLabels: [], + assignees: [], + onStateChange: vi.fn(), + onLabelsChange: vi.fn(), + onMutated: vi.fn(), + onUse: vi.fn() + }) + for (const request of fixture.loads.filter((load) => load.key !== null)) { + await request.load() + } + expect(labels).toHaveBeenCalledWith({ + owner: 'project-owner', + repo: 'outside', + host: 'ghe.example' + }) + expect(users).toHaveBeenCalledWith({ + owner: 'project-owner', + repo: 'outside', + host: 'ghe.example' + }) + expect(fixture.requests).toEqual([]) +}) + +it('rolls back a rejected fork label without changing the upstream row', async () => { + const store = createTestStore() + const key = workItemsCacheKey(registeredRepo.id, 36, '', getTaskSourceCacheScope(sourceContext)) + store.setState({ workItemsCache: { [key]: { data: [upstream, fork], fetchedAt: 1 } } }) + fixture.gh.mockRejectedValueOnce(new Error('Fixture rejects label')) + let pending = Promise.resolve(false) + const observed: string[][][] = [] + runGHEditLabelToggle({ + itemId: fork.id, + itemNumber: fork.number, + itemRepoId: fork.repoId, + repoPath: registeredRepo.path, + sourceContext, + projectOrigin: undefined, + issueRepo, + label: 'fork-only-label', + localLabels: [], + run: (_key, options) => { + pending = (async () => { + options.onOptimistic?.() + observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? []) + try { + await options.mutate() + return true + } catch { + options.onRevert?.() + observed.push(store.getState().workItemsCache[key]?.data?.map((row) => row.labels) ?? []) + return false + } + })() + return pending + }, + onLabelsChange: vi.fn(), + patchWorkItem: store.getState().patchWorkItem, + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + expect(await pending).toBe(false) + expect(observed).toEqual([ + [[], ['fork-only-label']], + [[], []] + ]) +}) + +it('does not fall back to upstream metadata for an invalid explicit repository', async () => { + const invalid = { owner: '../escape', repo: 'widgets', host: 'github.com' } + await expect(listLabels(registeredRepo.path, 'upstream', null, {}, invalid)).resolves.toEqual([]) + await expect( + listAssignableUsers(registeredRepo.path, 'upstream', null, {}, invalid) + ).resolves.toEqual([]) + expect(fixture.requests).toEqual([]) +}) diff --git a/config/scripts/github-pr-assignee-repository.test.ts b/config/scripts/github-pr-assignee-repository.test.ts new file mode 100644 index 00000000000..0265aa92c41 --- /dev/null +++ b/config/scripts/github-pr-assignee-repository.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type * as GhUtils from '../../src/main/github/gh-utils' +import type * as ReactModule from 'react' +import type { GitHubOwnerRepo } from '../../src/shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../src/shared/github/work-item-types' +import type { GitHubIssueUpdate } from '../../src/shared/issue-mutation-types' + +const fixture = vi.hoisted(() => { + const state: { + callbacks: unknown[] + mutation: Promise | null + gh: ReturnType + apiUpdate: ReturnType + patch: ReturnType + preference: 'origin' | 'upstream' + localGitOptions: { wslDistro?: string } + requests: { args: string[]; host?: string; cwd?: string; wslDistro?: string }[] + } = { + callbacks: [], + mutation: null, + gh: vi.fn(), + apiUpdate: vi.fn(), + patch: vi.fn(), + preference: 'origin', + localGitOptions: {}, + requests: [] + } + return state +}) + +vi.mock('react', async (original) => ({ + ...(await original()), + useState: (value: unknown) => [typeof value === 'function' ? value() : value, vi.fn()], + useMemo: (getValue: () => T) => getValue(), + useCallback: (callback: T) => { + fixture.callbacks.push(callback) + return callback + } +})) +vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: T) => selector })) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ + patchWorkItem: fixture.patch, + patchProjectRowContent: fixture.patch, + repos: [], + settings: {} + }), + { getState: () => ({ recordFeatureInteraction: vi.fn() }) } + ) +})) +vi.mock('@/lib/repo-runtime-owner', () => ({ + getSettingsForRepoRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null }) +})) +vi.mock('@/components/ui/popover', () => ({ + Popover: vi.fn(), + PopoverContent: vi.fn(), + PopoverTrigger: vi.fn() +})) +vi.mock('@/hooks/useIssueMetadata', () => ({ + useRepoAssignees: () => ({ data: [], loading: false, error: null }), + useImmediateMutation: () => ({ + isPending: () => false, + run: (_key: string, spec: { mutate: () => Promise }) => { + fixture.mutation = spec.mutate() + } + }) +})) +vi.mock('@/hooks/useGitHubSlugMetadata', () => ({ + useRepoAssigneesBySlug: () => ({ + data: [{ login: 'octo', name: null, avatarUrl: '' }], + loading: false, + error: null + }) +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/components/github/work-item-state-presentation', () => ({ ReviewerAvatar: vi.fn() })) +vi.mock('../../src/main/github/gh-utils', async (original) => ({ + ...(await original()), + ghExecFileAsync: fixture.gh, + acquire: vi.fn(), + release: vi.fn(), + getOwnerRepoForRemote: async (_path: string, remote: string) => ({ + owner: remote === 'upstream' ? 'upstream-owner' : 'fork-owner', + repo: 'widgets' + }) +})) +vi.mock('../../src/main/git/remote-name-listing', () => ({ + shouldProbeGitRemote: async () => true +})) + +import { PRAssigneesPanel } from '../../src/renderer/src/components/github/PRAssigneesPanel' +import { updateIssue } from '../../src/main/github/issue-update' +import { _resetOriginGitHubApiRepositoryCache } from '../../src/main/github/github-api-repository' + +const repoPath = join(tmpdir(), 'orca-pr-assignee-repository-fixture') + +beforeEach(() => { + fixture.callbacks = [] + fixture.mutation = null + fixture.requests = [] + fixture.localGitOptions = {} + fixture.gh.mockReset() + fixture.gh.mockImplementation( + async ( + args: string[], + options: { + host?: string + cwd?: string + wslDistro?: string + } + ) => { + fixture.requests.push({ + args, + host: options.host, + cwd: options.cwd, + wslDistro: options.wslDistro + }) + return { stdout: '', stderr: '' } + } + ) + _resetOriginGitHubApiRepositoryCache() + fixture.apiUpdate = vi.fn( + (args: { + repoPath: string + number: number + updates: GitHubIssueUpdate + ownerRepo?: GitHubOwnerRepo + }) => + updateIssue( + args.repoPath, + args.number, + args.updates, + null, + fixture.localGitOptions, + fixture.preference, + args.ownerRepo + ) + ) + vi.stubGlobal('window', { api: { gh: { updateIssue: fixture.apiUpdate } } }) +}) + +afterEach(() => vi.unstubAllGlobals()) + +it.each([ + { owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: false }, + { owner: 'upstream-owner', preference: 'origin', assigned: true, legacy: false }, + { owner: 'fork-owner', preference: 'upstream', assigned: false, legacy: false }, + { owner: 'fork-owner', preference: 'upstream', assigned: true, legacy: false }, + { owner: 'upstream-owner', preference: 'origin', assigned: false, legacy: true } +] as const)( + 'keeps $owner PR assignees under $preference (remove=$assigned, legacy=$legacy)', + async ({ owner, preference, assigned, legacy }) => { + fixture.preference = preference + fixture.localGitOptions = owner === 'fork-owner' ? { wslDistro: 'Ubuntu' } : {} + const item: GitHubWorkItem = { + id: 'pr:5', + type: 'pr', + number: 5, + title: 'Opened PR', + state: 'open', + url: `https://github.com/${owner}/widgets/pull/5`, + prRepo: legacy ? undefined : { owner, repo: 'widgets', host: 'github.com' }, + labels: [], + updatedAt: '', + author: null, + repoId: 'repo-1', + assignees: assigned ? [{ login: 'octo', name: null, avatarUrl: '' }] : [] + } + PRAssigneesPanel({ item, repoPath, projectOrigin: undefined, onMutated: vi.fn() }) + const toggleAssignee = fixture.callbacks.at(-1) + if (typeof toggleAssignee !== 'function') { + throw new Error('PR panel did not create an assignee handler') + } + toggleAssignee('octo') + await fixture.mutation + expect(fixture.requests).toEqual([ + { + args: [ + 'issue', + 'edit', + '5', + '--repo', + `${owner}/widgets`, + assigned ? '--remove-assignee' : '--add-assignee', + 'octo' + ], + host: 'github.com', + cwd: repoPath, + wslDistro: fixture.localGitOptions.wslDistro + } + ]) + expect(fixture.apiUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath, + repoId: item.repoId, + ownerRepo: { owner, repo: 'widgets', host: 'github.com' } + }) + ) + } +) diff --git a/config/scripts/json-parser-benchmark-fixtures.mjs b/config/scripts/json-parser-benchmark-fixtures.mjs new file mode 100644 index 00000000000..385b4010895 --- /dev/null +++ b/config/scripts/json-parser-benchmark-fixtures.mjs @@ -0,0 +1,65 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export const JSON_PARSER_CASES = [ + { name: 'rg-10k', kind: 'rg', count: 10_000, cap: 2000 }, + { name: 'rg-100k', kind: 'rg', count: 100_000, cap: 2000 }, + { name: 'rg-100k-cap1', kind: 'rg', count: 100_000, cap: 1 }, + { name: 'rg-unicode', kind: 'rg', count: 10_000, cap: 2000, unicode: true }, + { name: 'rg-large-dense', kind: 'rg', count: 900_000, cap: 2000, large: true }, + { name: 'rg-fast-path', kind: 'rg', count: 1, cap: 2000, large: true }, + { name: 'session-messages', kind: 'session' }, + { name: 'session-skipped-objects', kind: 'session' }, + { name: 'session-skipped-string', kind: 'session' }, + { name: 'session-selected-string', kind: 'session' } +] + +export function writeJsonParserFixtures(directory) { + for (const fixture of JSON_PARSER_CASES) { + let value + if (fixture.kind === 'rg') { + const text = fixture.unicode + ? '\ufeff日本語😀x' + : fixture.large && fixture.count > 1 + ? 'xxxx' + : 'x' + const matchBytes = Buffer.byteLength(text) + value = { + type: 'match', + data: { + path: { text: `${text}.ts` }, + lines: { + text: text.repeat(fixture.count === 1 ? 4 * 1024 * 1024 : fixture.count) + }, + line_number: 1, + submatches: Array.from({ length: fixture.count }, (_, index) => ({ + match: { text }, + start: index * matchBytes, + end: (index + 1) * matchBytes + })) + } + } + } else { + value = { id: 'synthetic', messages: [{ text: 'one' }] } + if (fixture.name === 'session-messages') { + value.agent = { model: 'model', other: 'ignored' } + value.messages = Array.from({ length: 20_000 }, (_, index) => ({ + role: index % 2 ? 'assistant' : 'user', + text: '\ufeff日本語😀 hello world '.repeat(16), + timestamp: index, + metadata: { model: 'synthetic', tokens: 512 } + })) + } else if (fixture.name === 'session-skipped-objects') { + value.ignored = Array.from({ length: 200_000 }, (_, index) => ({ + id: index, + data: { text: 'x'.repeat(64), values: [1, 2, 3] } + })) + } else if (fixture.name === 'session-skipped-string') { + value.ignored = '日本語😀x'.repeat(1_500_000) + } else { + value.messages = [{ text: '日本語😀x'.repeat(1_500_000) }] + } + } + writeFileSync(join(directory, `${fixture.name}.json`), JSON.stringify(value)) + } +} diff --git a/config/scripts/json-parser-migration-benchmark.mjs b/config/scripts/json-parser-migration-benchmark.mjs new file mode 100644 index 00000000000..0aca1734e1b --- /dev/null +++ b/config/scripts/json-parser-migration-benchmark.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { createReadStream, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' +import { JSON_PARSER_CASES, writeJsonParserFixtures } from './json-parser-benchmark-fixtures.mjs' + +// Bundle each revision's two consumers as {baseline,candidate}-{rg,session}.mjs first. +const [bundleDirectory, workerFixture, workerArm] = process.argv.slice(2) +if (!bundleDirectory || !global.gc) { + throw new Error('Usage: node --expose-gc json-parser-migration-benchmark.mjs BUNDLE_DIRECTORY') +} + +async function load(arm, kind) { + return import(pathToFileURL(resolve(bundleDirectory, `${arm}-${kind}.mjs`)).href) +} + +function prepareRun(module, fixture, file) { + if (fixture.kind === 'rg') { + const text = readFileSync(file, 'utf8') + return () => + module.parseRipgrepMatchJson(text, fixture.cap, { + structuralTokens: 32 * 1024, + nestingDepth: 16 + }) + } + return () => + module.readStreamedSessionDocument({ + bytes: createReadStream(file, { highWaterMark: 64 * 1024 }), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: () => ({ count: 0, textLength: 0 }), + consume(state, value) { + state.count++ + state.textLength += typeof value?.text === 'string' ? value.text.length : 0 + } + }) +} + +function digest(value) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +async function consumedContentDigest(module, file) { + const result = await module.readStreamedSessionDocument({ + bytes: createReadStream(file, { highWaterMark: 64 * 1024 }), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: () => createHash('sha256'), + consume(hash, value) { + hash.update(JSON.stringify(value)).update('\n') + } + }) + return { record: result.record, consumedSha256: result.state.digest('hex') } +} + +if (workerFixture) { + const fixture = JSON_PARSER_CASES.find((item) => workerFixture.endsWith(`${item.name}.json`)) + assert(fixture) + const module = await load(workerArm, fixture.kind) + const run = prepareRun(module, fixture, workerFixture) + global.gc() + const before = process.memoryUsage() + let running = true + let maxLoopGapMs = 0 + let previous = performance.now() + const observe = () => { + const now = performance.now() + maxLoopGapMs = Math.max(maxLoopGapMs, now - previous) + previous = now + if (running) { + setImmediate(observe) + } + } + setImmediate(observe) + const started = performance.now() + const result = await run() + const elapsedMs = performance.now() - started + await new Promise((done) => setImmediate(done)) + running = false + const peakRssMiB = process.resourceUsage().maxRSS / 1024 + global.gc() + const retainedHeapDeltaMiB = (process.memoryUsage().heapUsed - before.heapUsed) / 1024 ** 2 + console.log( + JSON.stringify({ + elapsedMs, + peakRssMiB, + retainedHeapDeltaMiB, + maxLoopGapMs, + digest: digest(result) + }) + ) +} else { + const directory = mkdtempSync(join(tmpdir(), 'orca-json-parser-benchmark-')) + try { + writeJsonParserFixtures(directory) + global.gc() + const results = [] + for (const fixture of JSON_PARSER_CASES) { + const file = join(directory, `${fixture.name}.json`) + const runs = {} + const contents = {} + for (const arm of ['baseline', 'candidate']) { + const module = await load(arm, fixture.kind) + runs[arm] = prepareRun(module, fixture, file) + if (fixture.kind === 'session') { + contents[arm] = await consumedContentDigest(module, file) + } + } + assert.deepEqual(contents.candidate, contents.baseline) + for (let warmup = 0; warmup < 3; warmup++) { + assert.deepEqual(await runs.candidate(), await runs.baseline()) + } + const samples = { baseline: [], candidate: [] } + for (const pair of buildCounterbalancedSchedule(12, 'baseline', 'candidate')) { + for (const arm of pair) { + const started = performance.now() + await runs[arm]() + samples[arm].push(performance.now() - started) + } + } + const memory = { baseline: [], candidate: [] } + for (const pair of buildCounterbalancedSchedule(2, 'baseline', 'candidate')) { + for (const arm of pair) { + const child = spawnSync( + process.execPath, + ['--expose-gc', import.meta.filename, bundleDirectory, file, arm], + { + encoding: 'utf8', + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + windowsHide: true + } + ) + assert.equal(child.status, 0, child.stderr) + memory[arm].push(JSON.parse(child.stdout)) + } + } + for (const sample of [...memory.baseline, ...memory.candidate]) { + assert.equal(sample.digest, memory.baseline[0].digest) + } + results.push({ + name: fixture.name, + bytes: statSync(file).size, + baseline: summarizeBenchmarkSamples(samples.baseline), + candidate: summarizeBenchmarkSamples(samples.candidate), + samples, + memory + }) + } + console.log( + JSON.stringify( + { node: process.version, platform: process.platform, arch: process.arch, results }, + null, + 2 + ) + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/localization-extraction-change-scope.test.mjs b/config/scripts/localization-extraction-change-scope.test.mjs index 8852965ad30..b1c142b8cd0 100644 --- a/config/scripts/localization-extraction-change-scope.test.mjs +++ b/config/scripts/localization-extraction-change-scope.test.mjs @@ -39,10 +39,10 @@ it('preserves deleted and renamed inputs and falls back to extraction on detecti const workflow = parse( readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8') ) - const step = workflow.jobs.static_analysis.steps.find( + const step = workflow.jobs.preflight.steps.find( (candidate) => candidate.name === 'Verify localization extraction' ) - expect(step.env).toEqual({ + expect(step.env).toMatchObject({ BASE_SHA: '${{ github.event.pull_request.base.sha }}' }) // The base side comes from the merge ref's first parent, so the gate needs no merge base and diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 4216beb2556..38b56f0d600 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -310,6 +310,10 @@ it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', ( expect(build.if).toBeUndefined() expect(build['continue-on-error']).toBeUndefined() expect(build.run).toMatch(/^if \[ "\$RUNNER_OS" != Linux \]; then exit 0; fi\n/) + expect(build.run).toContain( + 'pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts' + ) + expect(build.run).not.toContain('"$GITHUB_WORKSPACE/node_modules"') expect(build.run).toContain('echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"') expect(build.run).toContain('echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"') expect(build.run).not.toContain('GITHUB_ENV') diff --git a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs index 5ee88ff0492..e3108d782be 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs @@ -92,7 +92,7 @@ it('only skips the unchanged smoke after a successful diff and dependency analys const workflow = parse( readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8') ) - const step = workflow.jobs.static_analysis.steps.find( + const step = workflow.jobs.preflight.steps.find( (candidate) => candidate.name === 'Boot orcad and round-trip a terminal' ) expect(step.env).toEqual({ diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index 588b32871bc..15be37ebcb2 100644 --- a/config/scripts/plain-node-entry-guard.test.ts +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Plugin, Rollup } from 'vite' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CLI_MAIN_ENTRY_NAMES, createPlainNodeEntryGuardPlugin, @@ -160,14 +160,20 @@ describe('guarded entry names', () => { // hand-written "must stay electron-free" comments, and the port-scan worker sits // one import away from a client that deliberately does require electron. describe('CLI and worker thread entry guard', () => { - function runEntryWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void { + function runEntryWriteBundle( + plugin: Plugin, + bundle: Rollup.OutputBundle, + watchMode = false + ): void { const hook = plugin.writeBundle if (typeof hook !== 'function') { throw new Error('Expected writeBundle hook') } hook.call( - { meta: { watchMode: false } } as never, - { dir: createOutputDir() } as Rollup.NormalizedOutputOptions, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook reads only meta.watchMode from its context. + { meta: { watchMode } } as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook reads only dir from the output options. + { dir: outputDir ?? createOutputDir() } as Rollup.NormalizedOutputOptions, bundle ) } @@ -184,6 +190,93 @@ describe('CLI and worker thread entry guard', () => { } as Rollup.OutputChunk } + function countElectronRequireScans(run: () => void): number { + const pattern = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/ + const originalTest = RegExp.prototype.test + let scans = 0 + const spy = vi.spyOn(RegExp.prototype, 'test').mockImplementation(function ( + this: RegExp, + value: string + ) { + if (this.source === pattern.source) { + scans += 1 + } + return originalTest.call(this, value) + }) + try { + run() + } finally { + spy.mockRestore() + } + return scans + } + + it('scans shared code once across every guarded entry', () => { + const shared = entryChunk('shared', 'require("node:fs")') + shared.isEntry = false + const bundle: Rollup.OutputBundle = { [shared.fileName]: shared } + for (const name of GUARDED_ENTRY_NAMES) { + const entry = entryChunk(name, `require("./shared.js"); // ${name}`, [shared.fileName]) + bundle[entry.fileName] = entry + } + const snapshot = structuredClone(bundle) + + const scans = countElectronRequireScans(() => { + runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle) + }) + + expect(bundle).toEqual(snapshot) + expect(scans).toBe(GUARDED_ENTRY_NAMES.length + 1) + }) + + it('does not retain a successful scan across output bundles', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const entry = entryChunk('stt-worker', 'require("node:fs")') + const bundle: Rollup.OutputBundle = { [entry.fileName]: entry } + const scans = countElectronRequireScans(() => { + runEntryWriteBundle(plugin, bundle) + runEntryWriteBundle(plugin, bundle) + }) + expect(scans).toBe(2) + + entry.code = 'require("electron/main")' + expect(() => runEntryWriteBundle(plugin, bundle)).toThrow( + '[plain-node-entry-guard] "stt-worker" reaches chunk "stt-worker.js" that requires electron. ' + ) + }) + + it('still reads changed code on the same chunk within one bundle scan', () => { + const first = entryChunk('stt-worker', 'require("node:fs")') + const second = entryChunk('warp-theme-parser-worker', 'require("node:fs")') + const shared = entryChunk('shared', '') + shared.isEntry = false + let reads = 0 + Object.defineProperty(shared, 'code', { + get: () => (++reads === 1 ? 'require("node:fs")' : 'require("electron")') + }) + first.imports = [shared.fileName] + second.dynamicImports = [shared.fileName] + const bundle: Rollup.OutputBundle = { + [first.fileName]: first, + [second.fileName]: second, + [shared.fileName]: shared + } + + expect(() => runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle)).toThrow( + '[plain-node-entry-guard] "warp-theme-parser-worker" reaches chunk "shared.js"' + ) + expect(reads).toBe(2) + }) + + it('keeps watch mode free of entry scanning', () => { + const entry = entryChunk('stt-worker', 'require("electron")') + const bundle: Rollup.OutputBundle = { [entry.fileName]: entry } + const scans = countElectronRequireScans(() => { + runEntryWriteBundle(createPlainNodeEntryGuardPlugin(), bundle, true) + }) + expect(scans).toBe(0) + }) + it.each(CLI_MAIN_ENTRY_NAMES)('rejects direct and transitive Electron imports in %s', (name) => { const plugin = createPlainNodeEntryGuardPlugin() const entry = entryChunk(name, 'require("electron")') diff --git a/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs b/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs new file mode 100644 index 00000000000..b263b86f789 --- /dev/null +++ b/config/scripts/pr-code-change-scope-cross-version-send-path.test.mjs @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +// #24901 changed the send builders and orchestration code, and this job skipped. It runs only for +// code a suite executes: loading a module the dispatcher registers is not coverage. +describe('cross-version wire routing for the send path', () => { + it.each([ + 'src/shared/agent-session-wire-refusals.ts', + 'src/shared/structured-agent-session-mutation.ts', + 'src/shared/structured-agent-session-send-mutation.ts', + 'src/shared/structured-agent-session-outbox.ts', + 'src/main/runtime/rpc/core.ts', + 'src/main/runtime/rpc/errors.ts', + 'src/main/runtime/rpc/rpc-streaming-dispatcher.ts', + 'src/main/runtime/rpc/orchestration-contract-fence.ts', + 'src/main/runtime/rpc/orchestration-session-caller.ts', + 'src/main/runtime/rpc/orchestration-legacy-compatibility.ts', + 'src/main/runtime/rpc/orchestration-mutation-executor.ts', + 'src/shared/orchestration-rpc-contract.ts', + 'src/main/runtime/orchestration/db/schema/migrate.ts' + ])('runs the cross-version suites when %s changes', (file) => { + expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': true }) + }) + + it.each([ + 'src/shared/structured-agent-session-outbox-admission.ts', + 'src/shared/structured-agent-session-outbox-delivery.ts', + 'src/shared/structured-agent-session-outbox-stop-withdrawal.ts', + 'src/shared/structured-agent-session-composer.ts', + 'src/shared/structured-agent-session-reducer.ts', + 'src/main/runtime/orchestration/send-agent-turn.ts', + 'src/main/runtime/orchestration/orchestration-caller-identity.ts', + 'src/main/runtime/rpc/orchestration-legacy-mail.ts', + 'src/main/runtime/rpc/methods/orchestration.ts', + 'src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts' + ])('leaves them off for %s, which no cross-version suite executes', (file) => { + expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': false }) + }) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 800854c8008..9c658533e82 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -165,6 +165,11 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/shared/rpc-contract/agent-launch-params', 'src/shared/agent-session-wire', 'src/shared/agent-session-mutation-envelope', + // The send a client builds (the agent-session suite sends it to the release host) and the + // fingerprint the host's ledger and journal re-derive. + 'src/shared/structured-agent-session-mutation.ts', + 'src/shared/structured-agent-session-send-mutation.ts', + 'src/shared/structured-agent-session-outbox.ts', 'src/shared/agent-session-record', 'src/shared/agent-session-journal-', 'src/main/ai-vault/structured-session-ownership.ts', @@ -174,6 +179,15 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/runtime/agent-session-recovery-capsule', 'src/shared/agent-session-resume-marker', 'src/main/runtime/rpc/dispatcher', + // Run on every request the suites dispatch, whatever its method. + 'src/main/runtime/rpc/core.ts', + 'src/main/runtime/rpc/errors.ts', + 'src/main/runtime/rpc/rpc-streaming-dispatcher.ts', + 'src/main/runtime/rpc/orchestration-contract-fence.ts', + 'src/main/runtime/rpc/orchestration-session-caller.ts', + 'src/main/runtime/rpc/orchestration-legacy-compatibility.ts', + 'src/main/runtime/rpc/orchestration-mutation-executor.ts', + 'src/shared/orchestration-rpc-contract.ts', 'src/main/runtime/rpc/methods/agent-launch', 'src/main/runtime/rpc/methods/ai-vault.ts', 'src/main/runtime/rpc/methods/browser-tab-create-schema', @@ -357,6 +371,8 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/runtime/unreadable-secret-store-preservation.win32.test.ts', 'src/main/ipc/pty-codex-account-attribution.test.ts', 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts', + 'src/main/ipc/preflight-provider-command-selection.test.ts', + 'src/main/ipc/preflight-runnable-local-cli.test.ts', 'src/relay/windows-port-scan.win32.test.ts', 'src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts', 'src/main/ssh/remote-node-runtime-store-windows.test.ts' diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 297d1f8e671..9bcc7974513 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -587,14 +587,16 @@ describe('PR Checks skip wiring', () => { expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe( '${{ steps.filter.outputs.mobile_dependencies }}' ) - const steps = prWorkflow.jobs.static_analysis.steps + const steps = prWorkflow.jobs.preflight.steps const install = steps.findIndex( (step) => step.uses === './.github/actions/install-mobile-dependencies' ) const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality') expect(install).toBeGreaterThan(-1) expect(install).toBeLessThan(gate) - expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'") + expect(steps[install].if).toBe( + "needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'" + ) // The install itself moved into the action the packaging jobs share; assert it there so // this job cannot keep the step while the action stops installing anything. const action = parse( @@ -621,21 +623,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')) { + for (const jobName of expensiveJobs.filter( + (jobName) => !['test', 'static_analysis', 'typecheck'].includes(jobName) + )) { expect(prWorkflow.jobs[jobName].needs, jobName).toEqual( ['package', 'package_windows'].includes(jobName) - ? ['code_paths', 'static_analysis', 'typecheck'] + ? ['code_paths', 'preflight'] : ['code_paths'] ) expect(prWorkflow.jobs[jobName].if, jobName).toBe( `needs.code_paths.outputs.${jobName} == 'true'` ) } - expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) - expect(prWorkflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") - expect(prWorkflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") + expect(prWorkflow.jobs.test.if).toContain("needs.preflight.result == 'success'") expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") - expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}') expect(prWorkflow.jobs.unit_plan).toBeUndefined() expect(prWorkflow.jobs.test_native_cache).toBeUndefined() }) @@ -659,7 +661,7 @@ describe('PR Checks skip wiring', () => { expect(verifyStep.run).toContain('expected skipped') expect(verifyStep.run).toContain('expected success') for (const job of prWorkflow.jobs.verify.needs) { - if (job === 'code_paths') { + if (job === 'code_paths' || job === 'preflight') { continue } const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN` diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 4c1268cd7a2..c661327a40a 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -45,7 +45,7 @@ const nativeImeSpec = readFileSync( const filterStep = prWorkflow.jobs.code_paths.steps.find( (step) => step.name === 'Filter changed E2E specs' ) -const rollbackStep = prWorkflow.jobs.static_analysis.steps.find( +const rollbackStep = prWorkflow.jobs.preflight.steps.find( (step) => step.name === 'Check VM runtime rollback compatibility' ) const verifyStep = prWorkflow.jobs.verify.steps.find( @@ -133,7 +133,7 @@ describe('PR E2E gate contract', () => { for (const job of prWorkflow.jobs.verify.needs) { const envVar = job.replaceAll('-', '_').toUpperCase() expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`) - if (job === 'code_paths') { + if (job === 'code_paths' || job === 'preflight') { continue } expect(successLoop).toContain(`"$${envVar}"`) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 84345ee9360..250ea94ffe8 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -1,29 +1,21 @@ import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' import { expect, it } from 'vitest' import { parse } from 'yaml' import { classifyPrJobs } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const typecheck = workflow.jobs.typecheck -const steps = typecheck.steps +const preflight = workflow.jobs.preflight +const steps = preflight.steps const compiler = steps.find((step) => step.run === 'pnpm run typecheck') const plan = steps.find((step) => step.id === 'unit-plan') -it('shares planning setup while keeping the heavy checks on separate runners', () => { - expect(workflow.jobs.unit_plan).toBeUndefined() - expect(workflow.jobs.test_native_cache).toBeUndefined() - expect(typecheck.needs).toEqual(['code_paths']) - expect(workflow.jobs.static_analysis.needs).toEqual(['code_paths']) - expect( - steps.filter((step) => step.uses === './.github/actions/install-node-dependencies') - ).toHaveLength(1) - expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) - expect(compiler.background).toBeUndefined() - expect(plan.background).toBe(true) - expect(plan.run).toBe('node config/scripts/ci-unit-plan.mjs') - expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') - expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) - const installs = workflow.jobs.static_analysis.steps.filter( +it('shares one setup and runs the unchanged compiler after static checks finish', () => { + expect(workflow.jobs.static_analysis).toBeUndefined() + expect(workflow.jobs.typecheck).toBeUndefined() + expect(preflight.needs).toEqual(['code_paths']) + expect(preflight['runs-on']).toBe('ubuntu-24.04-arm') + const installs = steps.filter( (step) => step.uses === './.github/actions/install-node-dependencies' ) expect(installs).toHaveLength(2) @@ -32,35 +24,152 @@ it('shares planning setup while keeping the heavy checks on separate runners', ( expect(install.with['node-version']).toBe('24') expect(install.with['persist-native-cache']).not.toBe('false') } + expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) + expect(compiler.background).toBeUndefined() + expect(plan.background).toBe(true) + expect(plan.run.trim().split('\n')).toEqual([ + 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi', + 'node config/scripts/ci-unit-plan.mjs' + ]) + expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') + expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) + expect(steps.indexOf(compiler)).toBeGreaterThan( + steps.findIndex((step) => step.wait?.includes('localization-extraction')) + ) }) -it('requires compiler success and joined planning before publishing shards and admitting tests', () => { +it('requires physical preflight success before publishing shards and admitting consumers', () => { const join = steps.findIndex((step) => step.wait === 'unit-plan') const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7') expect(join).toBeGreaterThan(steps.indexOf(compiler)) expect(upload).toBeGreaterThan(join) expect(steps[upload]['continue-on-error']).toBeUndefined() expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}') - expect(typecheck.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') - expect(workflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(preflight.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') + expect(workflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}') for (const job of ['test', 'package', 'package_windows']) { - expect(workflow.jobs[job].needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) + expect(workflow.jobs[job].needs).toEqual(['code_paths', 'preflight']) } - expect(workflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") - expect(workflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") - expect(workflow.jobs.verify.needs).toContain('static_analysis') - expect(workflow.jobs.verify.needs).toContain('typecheck') + for (const result of ['success', 'failure', 'cancelled', 'skipped']) { + const admitted = runInNewContext(workflow.jobs.test.if, { + cancelled: () => false, + needs: { code_paths: { outputs: { test: 'true' } }, preflight: { result } } + }) + expect(admitted, result).toBe(result === 'success') + } + const verify = workflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + expect(verify.env.PREFLIGHT).toBe('${{ needs.preflight.result }}') + expect(verify.env.PREFLIGHT_SHOULD_RUN).toBe( + "${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}" + ) + expect(verify.run).toContain('check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"') + expect(workflow.jobs.verify.needs).toContain('preflight') + expect(workflow.jobs.verify.needs).not.toContain('typecheck') }) -it.each( - [['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map( - (changed) => ({ changed }) +it('pins every foreground and background step to its selected phase', () => { + const staticPhase = "needs.code_paths.outputs.static_analysis == 'true'" + const typePhase = "needs.code_paths.outputs.typecheck == 'true'" + const foreground = steps.filter( + (step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '') ) -)('keeps desktop typechecking and planning off unrelated paths: $changed', ({ changed }) => { + expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([ + ['Reject low-evidence patterns', staticPhase], + ['Enforce type-aware code-quality baseline', staticPhase], + [ + './.github/actions/install-mobile-dependencies', + `${staticPhase} && needs.code_paths.outputs.mobile_dependencies == 'true'` + ], + ['Enforce React Doctor on changed lines', staticPhase], + ['Check Zustand selector fan-out budget', staticPhase], + ['Check reliability gate manifest', staticPhase], + ['Enforce dead design-system classes', staticPhase], + ['Check VM runtime rollback compatibility', staticPhase], + ['Enforce max-lines ratchet', staticPhase], + ['Enforce ts-nocheck ratchet', staticPhase], + ['Enforce runtime Electron-import ratchet', staticPhase], + ['Check Node runtime pin', staticPhase], + ['Boot orcad and round-trip a terminal', staticPhase], + ['Verify the generated RPC params catalog', staticPhase], + ['Verify bundled skill guides', staticPhase], + ['Verify skill freshness manifest', staticPhase], + ['Verify localization coverage', staticPhase], + ['Guard against project-owned .d.ts in preload/shared', staticPhase], + ['Check feature wall asset budget', staticPhase], + ['Verify macOS entitlements', staticPhase], + ['Cache TypeScript incremental state', typePhase], + ['pnpm run typecheck', typePhase], + ['actions/upload-artifact@v7', typePhase] + ]) + expect( + steps + .filter((step) => step.background) + .map((step) => [step.id, step.env.PREFLIGHT_PHASE_SELECTED]) + ).toEqual([ + ['root-lint', `\${{ ${staticPhase} }}`], + ['native-code-quality', `\${{ ${staticPhase} }}`], + ['changed-code-quality', `\${{ ${staticPhase} }}`], + ['localization-extraction', `\${{ ${staticPhase} }}`], + ['localization-catalogs', `\${{ ${staticPhase} }}`], + ['unit-plan', `\${{ ${typePhase} }}`] + ]) +}) + +it.each([ + { changed: ['README.md'], static_analysis: false, typecheck: false, mobile_dependencies: false }, + { + changed: ['mobile/src/App.tsx'], + static_analysis: true, + typecheck: false, + mobile_dependencies: true + }, + { + changed: ['cloud/package.json'], + static_analysis: false, + typecheck: false, + mobile_dependencies: false + }, + { + changed: ['src/main/index.ts'], + static_analysis: true, + typecheck: true, + mobile_dependencies: false + }, + { + changed: ['mobile/src/App.tsx', 'src/main/index.ts'], + static_analysis: true, + typecheck: true, + mobile_dependencies: true + }, + { changed: [], static_analysis: true, typecheck: true, mobile_dependencies: true } +])('preserves exact phase selection for unrelated paths: $changed', ({ changed, ...expected }) => { const scope = classifyPrJobs(changed) - expect(typecheck.if).toBe("needs.code_paths.outputs.typecheck == 'true'") - expect(scope.test).toBe(scope.typecheck) - if (scope.test) { - expect(scope.static_analysis).toBe(true) + expect({ + static_analysis: scope.static_analysis, + typecheck: scope.typecheck, + mobile_dependencies: scope.mobile_dependencies + }).toEqual(expected) + const needs = { + code_paths: { + outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)])) + } + } + expect(runInNewContext(preflight.if, { needs })).toBe( + expected.static_analysis || expected.typecheck + ) + expect(runInNewContext(compiler.if, { needs })).toBe(expected.typecheck) + expect(scope.test).toBe(expected.typecheck) +}) + +it('registers successful no-op background work when a phase is unselected or already failed', () => { + for (const step of steps.filter((step) => step.background)) { + expect(step.if).toBe('!cancelled()') + expect(step.env.PREFLIGHT_PHASE_SELECTED).toContain('needs.code_paths.outputs.') + expect(step.env.PREFLIGHT_PRIOR_SUCCESS).toBe("${{ job.status == 'success' }}") + expect(step.run.split('\n')[0]).toBe( + 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi' + ) } }) diff --git a/config/scripts/pr-ready-check-gate.test.mjs b/config/scripts/pr-ready-check-gate.test.mjs index 27e5cf99103..ade7fada96a 100644 --- a/config/scripts/pr-ready-check-gate.test.mjs +++ b/config/scripts/pr-ready-check-gate.test.mjs @@ -7,10 +7,14 @@ import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks') const variable = (job) => job.replaceAll('-', '_').toUpperCase() +const requiredJobs = [ + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') +] function requiredResults(shouldRun) { return Object.fromEntries( - PR_CHECK_JOBS.flatMap((job) => [ + requiredJobs.flatMap((job) => [ [variable(job), shouldRun ? 'success' : 'skipped'], [`${variable(job)}_SHOULD_RUN`, String(shouldRun)] ]) @@ -42,7 +46,7 @@ describe.skipIf(process.platform === 'win32')( }) it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => { - for (const job of PR_CHECK_JOBS) { + for (const job of requiredJobs) { for (const result of ['', 'skipped', 'failure', 'cancelled']) { const verdict = await verify({ ...requiredResults(true), [variable(job)]: result }) expect(verdict.code, `${job}: ${result}`).toBe(1) @@ -57,7 +61,7 @@ describe.skipIf(process.platform === 'win32')( }) it('rejects unexpected downstream execution when the proven plan requires skips', async () => { - for (const job of PR_CHECK_JOBS) { + for (const job of requiredJobs) { const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' }) expect(verdict.code, job).toBe(1) } diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index 19fb2407233..11f171a2b25 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -157,7 +157,11 @@ describe('ready-for-review required check reuse', () => { "github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'" ) expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}') - expect(workflow.jobs.verify.needs).toEqual(['code_paths', ...PR_CHECK_JOBS]) + expect(workflow.jobs.verify.needs).toEqual([ + 'code_paths', + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') + ]) }) it.each(['pr-test-loc.yml', 'mobile.yml'])( diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 91156e59bf0..7bc063b1d42 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -27,6 +27,7 @@ const shellContractFiles = [ '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/zsh-deferred-startup-line-init.live-shell.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', @@ -54,7 +55,7 @@ const realZshUsage = describe('PR workflow parallelism', () => { it('keeps lightweight orchestration jobs on the free slim runner', () => { expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim') - expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm') + expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim') expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim') expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim') @@ -79,7 +80,7 @@ describe('PR workflow parallelism', () => { const installStep = sharedTest.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const staticInstall = workflow.jobs.static_analysis.steps.find( + const staticInstall = workflow.jobs.preflight.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find( @@ -91,7 +92,7 @@ describe('PR workflow parallelism', () => { expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26']) expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm') - expect(workflow.jobs.static_analysis['runs-on']).toBe('ubuntu-24.04-arm') + expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}') expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest') expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined() @@ -121,7 +122,7 @@ describe('PR workflow parallelism', () => { } expect(staticInstall.with['native-runtime']).toBe('node') expect(staticInstall.with['node-version']).toBe('24') - expect(workflow.jobs.test.needs).toContain('static_analysis') + expect(workflow.jobs.test.needs).toContain('preflight') expect(workflow.jobs.test_native_cache).toBeUndefined() expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node') expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}') @@ -348,11 +349,11 @@ describe('PR workflow parallelism', () => { (step) => step.uses === './.github/actions/install-node-dependencies' ) - for (const jobName of ['typecheck', 'git_compatibility']) { + for (const jobName of ['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('preflight').with['native-runtime']).toBe('node') expect(installFor('shell_contracts').with['native-runtime']).toBe('node') expect(sharedTestInstall.with['native-runtime']).toBe('node') expect(installFor('package').with['native-runtime']).toBe('electron') @@ -478,7 +479,7 @@ describe('PR workflow parallelism', () => { }) it('reuses TypeScript incremental state across typecheck runs', () => { - const steps = workflow.jobs.typecheck.steps + const steps = workflow.jobs.preflight.steps const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state') const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck') @@ -543,8 +544,7 @@ describe('PR workflow parallelism', () => { it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ 'code_paths', - 'static_analysis', - 'typecheck', + 'preflight', 'git_compatibility', 'codex_index_heal_contract', 'xterm_patch_sync', diff --git a/config/scripts/stable-release-tag-selection-budget.test.mjs b/config/scripts/stable-release-tag-selection-budget.test.mjs new file mode 100644 index 00000000000..1571b0b9a32 --- /dev/null +++ b/config/scripts/stable-release-tag-selection-budget.test.mjs @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { selectLatestStableReleaseTag } from './stable-release-tags.mjs' + +function expectedLatest(tags) { + let latest = null + let latestParts = [] + for (const tag of tags) { + const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag) + if (!match) { + continue + } + const parts = match.slice(1).map((part) => { + const value = Number.parseInt(part, 10) + return Number.isFinite(value) ? value : 0 + }) + let comparison = 0 + for (let index = 0; index < 3 && comparison === 0; index++) { + comparison = parts[index] - (latestParts[index] ?? 0) + } + if (latest === null || comparison >= 0) { + latest = tag + latestParts = parts + } + } + return latest +} + +function measurePartMaps(tags) { + const nativeMap = Array.prototype.map + let partMaps = 0 + Array.prototype.map = function (callback, thisArg) { + partMaps++ + return nativeMap.call(this, callback, thisArg) + } + let result + try { + result = selectLatestStableReleaseTag(tags) + } finally { + Array.prototype.map = nativeMap + } + return { result, partMaps } +} + +describe('stable release tag selection work', () => { + it.each([0, 1, 12, 128, 1000])('bounds numeric parsing for %i Git tag strings', (count) => { + const tags = Array.from( + { length: count }, + (_, index) => `v1.${(index * 37) % 17}.${(index * 101) % (count + 1)}` + ) + const input = [...tags] + const expected = expectedLatest(tags) + const measured = measurePartMaps(Object.freeze(tags)) + expect(measured.result).toBe(expected) + expect(tags).toEqual(input) + // Each unchanged comparator converts the two triples through four maps. + expect(measured.partMaps).toBeLessThanOrEqual(4 * Math.max(0, count - 1)) + }) + + it('preserves tie spelling, numeric fallback and invalid-tag admission', () => { + const cases = [ + { tags: [], expected: null }, + { tags: ['nightly', 'mobile-v1.2.3', 'v1.2.3-rc.1'], expected: null }, + { tags: ['v0001.4.003', 'v1.04.3'], expected: 'v1.04.3' }, + { tags: ['v1.04.3', 'v0001.4.003'], expected: 'v0001.4.003' }, + { tags: ['v1.2.3', 'v1.2.3\n'], expected: 'v1.2.3' }, + { tags: ['v1.2.3\r\n', 'v0.0.1'], expected: 'v0.0.1' }, + { tags: [`v${'9'.repeat(400)}.1.2`, 'v0.1.2'], expected: 'v0.1.2' }, + { + tags: ['v9007199254740993.1.0', 'v9007199254740992.1.0'], + expected: 'v9007199254740992.1.0' + } + ] + for (const { tags, expected } of cases) { + const input = [...tags] + expect(selectLatestStableReleaseTag(Object.freeze(tags))).toBe(expected) + expect(tags).toEqual(input) + } + const sparse = [] + sparse.length = 12 + sparse[3] = 'v1.2.3' + sparse[8] = 'v2.0.0' + expect(selectLatestStableReleaseTag(Object.freeze(sparse))).toBe('v2.0.0') + }) + + it('selects the same latest spelling across ordinary version namespaces and repeated calls', () => { + let randomState = 673151 + const next = () => { + randomState = (randomState * 1664525 + 1013904223) >>> 0 + return randomState + } + for (let seed = 0; seed < 256; seed++) { + const tags = [] + for (let index = 0, count = next() % 129; index < count; index++) { + const triple = [next() % 13, next() % 17, next() % 257] + const prefix = next() % 8 === 0 ? 'mobile-v' : 'v' + const suffix = next() % 9 === 0 ? '-rc.1' : '' + tags.push(`${prefix}${triple.join('.')}${suffix}`) + if (index % 11 === 0) { + tags.push(tags.at(-1)) + } + } + const input = [...tags] + const expected = expectedLatest(tags) + expect(selectLatestStableReleaseTag(tags)).toBe(expected) + expect(tags).toEqual(input) + tags.push('v99.99.99') + expect(selectLatestStableReleaseTag(tags)).toBe('v99.99.99') + } + }) +}) diff --git a/config/scripts/stable-release-tags.mjs b/config/scripts/stable-release-tags.mjs index 2650f23eaa8..09032370dc7 100644 --- a/config/scripts/stable-release-tags.mjs +++ b/config/scripts/stable-release-tags.mjs @@ -21,10 +21,14 @@ export function compareReleaseTags(a, b) { /** @param {string[]} tags @returns {string | null} */ export function selectLatestStableReleaseTag(tags) { - return ( - tags - .filter((tag) => STABLE_DESKTOP_RELEASE_TAG.test(tag)) - .sort(compareReleaseTags) - .at(-1) ?? null - ) + let latest = null + for (const tag of tags) { + if ( + STABLE_DESKTOP_RELEASE_TAG.test(tag) && + (latest === null || compareReleaseTags(latest, tag) <= 0) + ) { + latest = tag + } + } + return latest } diff --git a/config/scripts/vitest-real-agent-home-write-guard.test.ts b/config/scripts/vitest-real-agent-home-write-guard.test.ts index 6a98c4aa577..9bd42217f67 100644 --- a/config/scripts/vitest-real-agent-home-write-guard.test.ts +++ b/config/scripts/vitest-real-agent-home-write-guard.test.ts @@ -4,7 +4,10 @@ import { writeFile } from 'node:fs/promises' import { homedir, tmpdir, userInfo } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { takeRealAgentHomeWriteViolations } from './vitest-real-agent-home-write-guard' +import { + clearInheritedAgentStateEnv, + takeRealAgentHomeWriteViolations +} from './vitest-real-agent-home-write-guard' // Why never-created paths: each sits under a missing folder or is a forced no-op removal, so even // with the guard off (the ablation) nothing lands in the real home. @@ -17,6 +20,29 @@ afterEach(() => { }) describe('vitest real-agent-home write guard', () => { + it.each([undefined, '', '0', 'true', '1'])( + 'keeps only the explicitly opted-in Claude profile for %s', + (value) => { + vi.stubEnv('ORCA_REAL_CLAUDE_CLI_TEST', value) + vi.stubEnv('CLAUDE_CONFIG_DIR', '/tmp/explicit-claude-profile') + vi.stubEnv('CODEX_HOME', '/tmp/inherited-codex-profile') + vi.stubEnv('ORCA_USER_DATA_PATH', '/tmp/inherited-orca-state') + vi.stubEnv('ORCA_CODEX_LAUNCH_PREFLIGHT', '/tmp/inherited-live-cli') + + clearInheritedAgentStateEnv() + + expect(process.env.CLAUDE_CONFIG_DIR).toBe( + value === '1' ? '/tmp/explicit-claude-profile' : undefined + ) + expect(process.env.CODEX_HOME).toBeUndefined() + expect(process.env.ORCA_USER_DATA_PATH).toBeUndefined() + expect(process.env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() + expect(() => rmSync(missingRealFolder('.claude'), { force: true })).toThrow( + /real-agent-home guard/ + ) + } + ) + it('refuses a named-import sync write under the real ~/.codex', () => { const target = join(missingRealFolder('.codex'), 'config.toml') expect(() => writeFileSync(target, '[projects."/tmp/x"]\n')).toThrow(/real-agent-home guard/) diff --git a/config/scripts/vitest-real-agent-home-write-guard.ts b/config/scripts/vitest-real-agent-home-write-guard.ts index 40c19fbcabc..e1f3fc3ee39 100644 --- a/config/scripts/vitest-real-agent-home-write-guard.ts +++ b/config/scripts/vitest-real-agent-home-write-guard.ts @@ -188,12 +188,18 @@ declare global { } const state = (globalThis.orcaRealAgentHomeWriteGuard ??= install()) // Why after install: the guard keeps the inherited paths as roots; tests that need one set their own. -const inheritedEnvToUnset = realAgentSuiteOptedIn() - ? [] - : [...INHERITED_STATE_ENV, ...INHERITED_LIVE_CLI_ENV] -for (const name of inheritedEnvToUnset) { - delete process.env[name] +export function clearInheritedAgentStateEnv(): void { + const inheritedEnvToUnset = realAgentSuiteOptedIn() + ? [] + : [...INHERITED_STATE_ENV, ...INHERITED_LIVE_CLI_ENV] + for (const name of inheritedEnvToUnset) { + if (name === 'CLAUDE_CONFIG_DIR' && process.env.ORCA_REAL_CLAUDE_CLI_TEST === '1') { + continue + } + delete process.env[name] + } } +clearInheritedAgentStateEnv() /** Drains recorded violations; only the guard's own self-test should need this. */ export function takeRealAgentHomeWriteViolations(): string[] { diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 44edcfd90b7..6afcf4f142f 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -258,8 +258,8 @@ ], "compilerOptions": { "composite": true, - // TypeScript 7 removed node10 resolution; Node16 preserves CommonJS emit for this package. - "module": "Node16", + // The CLI runs on Node 24; Node20 models synchronous ESM imports from CommonJS. + "module": "Node20", "moduleResolution": "Node16", "rootDir": "../src", "outDir": "../out" diff --git a/config/tsconfig.tc.cli.json b/config/tsconfig.tc.cli.json index bd59f47e7be..c613dd688d9 100644 --- a/config/tsconfig.tc.cli.json +++ b/config/tsconfig.tc.cli.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.cli.json", "compilerOptions": { - "module": "node16", + "module": "Node20", "moduleResolution": "node16" } } diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index fdf7ecb03a0..3f3cf232ad1 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 90m + + downloads: 91m @@ -15,7 +15,7 @@ downloads downloads - 90m - 90m + 91m + 91m diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 17ec41e8294..40f3cd2d23f 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -231,7 +231,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) --- diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index c8c4c18e596..71689cc0ceb 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) · [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.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -239,7 +239,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) -- **Android :** [Télécharger l'APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) +- **Android :** [Télécharger l'APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index ed37efa595f..df14248b9f7 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](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](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -231,7 +231,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 77cbb42154e..e984ceffa30 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) · [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.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -234,7 +234,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **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) +- **Android:** [APK 0.0.52 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/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 baa17f95955..3e3d0e6b1a5 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) · [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.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -234,7 +234,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) -- **Android:** [Baixar APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) +- **Android:** [Baixar APK 0.0.52](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 6da8f2c38ed..8e91e70d8f6 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](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](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -231,7 +231,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk) --- diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 8130b770dd9..58cef59dde5 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -46,6 +46,112 @@ used 42 aggregate runner-minutes across 11 test jobs. The estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are baseline observations; post-merge savings have not yet been measured. +## October 4 clock, byte and import test fixtures + +These changes retain production behavior, original case names and platform +outcomes. The paired pilots use three alternating one-worker Node 24 invocations +on Ubuntu 24 ARM. Every median below is a complete focused test invocation; +they do not establish whole-shard savings or queue-delay improvements. + +| Workload | Baseline median | Candidate median | Reduction | Hosted evidence | +| ---------------------------------------------------- | --------------- | ---------------- | --------- | ------------------------------------------------------------------------ | +| Codex settlement and Claude stop deadlines, 35 cases | 35.914s | 10.142s | 71.8% | [37186232658](https://github.com/stablyai/orca/actions/runs/37186232658) | +| Native-chat delivery, 15 cases | 22.321s | 7.376s | 67.0% | [37183731823](https://github.com/stablyai/orca/actions/runs/37183731823) | +| Six profile-storage byte suites, 118 cases | 12.629s | 9.978s | 21.0% | [37183141654](https://github.com/stablyai/orca/actions/runs/37183141654) | +| Encrypted account storage, six cases | 18.277s | 0.958s | 94.8% | [37184007241](https://github.com/stablyai/orca/actions/runs/37184007241) | +| SSH remote commands, 27 cases | 6.840s | 6.078s | 11.1% | [37184454532](https://github.com/stablyai/orca/actions/runs/37184454532) | +| OpenCode subscription, 28 cases | 47.347s | 6.284s | 86.7% | [37184858823](https://github.com/stablyai/orca/actions/runs/37184858823) | +| Window-service attachment, 31 cases | 11.910s | 1.619s | 86.4% | [37186340840](https://github.com/stablyai/orca/actions/runs/37186340840) | +| OpenCode 2 TUI ownership, 41 cases | 16.811s | 2.429s | 85.6% | [37187699312](https://github.com/stablyai/orca/actions/runs/37187699312) | +| Title-send authorization, nine cases | 29.545s | 12.995s | 56.0% | [37188423318](https://github.com/stablyai/orca/actions/runs/37188423318) | +| Range selection, 15 cases | 9.737s | 7.130s | 26.8% | [37188996198](https://github.com/stablyai/orca/actions/runs/37188996198) | + +Provider tests wait for the real fake-child write/ready barrier and drain the +host stream before installing a scoped parent clock. The original 2.5/5-second +Codex and 3-second Claude deadlines remain; before/at assertions check their +boundaries. Early rejection and refusal resolve without waiting on an unreachable +barrier; finally and suite teardown restore clocks and spies even after a native +Vitest timeout. Four healthy failure-path controls pass; old-helper hangs and +removed teardown are caught. Missing-child failure retains a real ten-second observation window. +Six faults for early/late stop deadlines, late queued resend and missing child +output fail the intended assertions. Native-chat tests still use the real React +outbox hooks. Their scoped clock retains probe, retry, churn and target-switch +windows, drains async act work, and unmounts before clock restoration. All eight +hook faults fail; two boundary faults pass the old coarse tests and fail the new +before/at assertions. Node/web typecheck, lint and formatting pass. + +Native Buffer.equals replaces deep per-byte assertion traversal. It checks the +complete original bytes and length; fixtures, SQLite operations, encryption and +processes are unchanged. The six profile suites retain 114 passes and four +existing Linux case-sensitivity skips; all 118 pass locally on macOS. Seven +profile last-byte/length faults and two encrypted-vault last-byte/length faults +fail their exact byte assertions. All six encrypted-vault cases still exercise +52 accounts near the 4 MiB encrypted cap, refused growth, restart/readback and +private permissions. + +The old SSH fixture created 15,197 short stage paths but never exceeded the real +1,048,576 UTF-16-character transport tail cap; its two valid entries also left +the 64-result assertion vacuous. The replacement uses about 1,300 real excluded +stage directories under long Unicode path components, a real shell channel and +the production execCommand limiter. Actual find output exceeds that cap, while +the generated filter retains both original valid entries. A separate population +of 65 valid directories proves the first-64 limit and native find ordering. File, +symlink, nested-install and failed-enumeration checks remain. All 27 case outcomes +match across treatments (23 passes and four unavailable PowerShell 5.1 skips on +the hosted image). Eight cap, ordering, filtering and failure faults are caught. +Paths use platform utilities; local PowerShell availability retains its original +skip policy. The deadline and SSH fixtures reuse existing process/stream code. +OpenCode subscription tests batch only uninterrupted fixture writes between the +original observation barriers. Both SQLite schema versions keep every row, rowid, +read cap, poll, clock position and case. No durability pragma or production code +changes. All 28 cases pass in every pair. Separate captures compare ordered +schema and rows, transaction state, pragmas, signals, page requests/results and +subscriber callbacks: 124,754,101 payload bytes match, canonical digest +`ba0eb6f09281746071d73fae88e2e8eb45332f36892b90998b374b1b8c59b3e3`. +Missing rows, collapsed frontier rowids, read-cap overruns, missing commit and +missing rollback each fail the intended case in both schemas. Positive rollback +controls pass. The unmeasured full-suite timing report ranked this fixture at +134.229 seconds; the paired 47.347-second figure above is the relevant focused +baseline, and the two figures must not be mixed into a claimed saving. + +Window attachment tests mock five unrelated registrar modules using their actual +types. The existing window ownership, reload, media permission, native file drop, +hydration barrier and updater scheduling cases remain real. Exact store/runtime/ +window arguments and daemon registration after the PTY handler are now asserted; +nine actual production wiring/order faults fail. The registrar implementations +retain separate handler tests. Concrete existing stubs moved to one fixture to +stay within the line limit. All 31 cases and statuses match across all pairs. The +final source differs from the timed source only by a required type-assertion +safety comment. +OpenCode 2 TUI tests use a scoped async clock only after the first real native +module import. The unchanged generated plugin runs against the existing fake TUI +and fetch. Original poll, permission, retry, preview, slow POST and endpoint +windows remain. Before/at assertions pin the 100 ms poll, 500 ms permission, +120 ms slow POST and 5-second endpoint boundaries. All 41 original case outcomes +match. Separate ordered captures preserve 678 TUI/event rows and 362 complete +POST start/completion rows; wall timestamps and cross-stream interleaving are +excluded from equivalence. Eleven generated-source faults fail their intended +identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/ +disposer rejection and timeout controls confirm restoration of clocks, fetch, +argv and environment. Teardown holds its fake clock through bounded native cleanup and pending-timer checks, then restores real clocks. The [teardown qualification](https://github.com/stablyai/orca/actions/runs/37195736834) passes all 41 cases and 12 failure and restoration control invocations, including interval and retry leaks missed by the earlier teardown. +Title-send authorization tests retain real terminal creation, graph binding and +positive evidence paths. Negative process-evidence probes use scoped clocks +after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at +6,600 ms, and the send guard retains its 1,050 ms deadline. Before/at assertions +check 6,599/6,600 and 1,049/1,050 ms. All nine original cases remain. Actual +early/late wrapper and guard deadlines, false spinner identity and unknown-agent +authorization faults fail their intended assertions; native clocks and runtime +instance spies are restored after each failure. +Range-selection tests stub only the saved-note send menu, which their empty +comment populations never render. A facade typed from the actual menu props +throws if invoked, and an afterEach assertion verifies no call with unconditional +mock clearing in finally. The real hook, Monaco constants/model, line and range +drag behavior, draft-card lifecycle and open-inline-card chord remain. All 15 +original test bodies are byte-unchanged. Three actual range/hunk/draft faults +fail their original assertions; a real draft-render menu call hits the facade +and sentinel, and an outer cleanup check proves mock state cleared after failure. +The actual saved-note menu retains its independent component tests. + ## October 2 headless detector compiler cache The deferred detector already avoids dependency setup for known build inputs. @@ -601,6 +707,8 @@ All commands passed and plans matched within each comparison. These command timings exclude setup and queues; compiler variation contributes to the cold difference. The retained arrangement showed no cold compiler penalty. +The sequential gate in [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity) supersedes the earlier rejection below. + Combining static analysis too was rejected. An [alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650) saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds @@ -1739,6 +1847,8 @@ these local body measurements do not establish hosted or whole-PR time savings. ## Sequential static analysis and typecheck: retain separate jobs +The earlier recommendation below is superseded by [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity). + A four-trial hosted screen kept the slim router unchanged and compared the two independent ARM jobs with one ARM job running their unchanged checks sequentially. The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888) @@ -1858,6 +1968,32 @@ the imported helper has no top-level side effects, and the Linux rebuild branch is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR checks qualify separately. +## October 4 reusable cells for terminal context scans + +Terminal cursor-context scans now request one reusable cell per invocation when +the adapter offers getNullCell, and pass it through all unchanged text/style +scans. Adapters without that optional method keep the existing allocating path. +The scratch cell is local and no cell reference escapes into returned context. +Browser composer/readiness text, colors, bold flags and wrapping are unchanged. + +Three alternating one-worker ARM pairs in +[37182789677](https://github.com/stablyai/orca/actions/runs/37182789677) +ran all 19 original cases from readiness census suite 2. Baseline complete +invocations were 37.141 / 37.879 / 37.090 seconds; candidate invocations were +33.887 / 33.387 / 32.916 seconds. Median 37.141 to 33.387 seconds saves 10.1%. +This is a focused workload measurement, not a whole-shard or queue-delay claim. + +Separate baseline/candidate captures retained all 192 cases across six census +suites. Every context and visible projection matched: 643,926 of each, with +7,465,308,324 complete length-prefixed payload bytes hashed per test/type/order. +The canonical capture digest was +`f7440c0f1b5bbb57127cd29245530029415c8e9e243c1744359f330b3c7ace19`. +These captures run outside the timing samples. All 41 cursor/composer/browser +consumer checks passed. Seven faults for lost dim filtering, wide continuation, +bold prompt, custom foreground, wrap preservation, adapter fallback and scratch +reuse failed their intended assertions. Node and web typecheck, lint and format +passed. Two added controls prove per-call scratch lifetime and adapter parity. + ## October 3 producer follow-up: automatic selection for the measured profile The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927) @@ -1895,6 +2031,36 @@ automatic selection and cold publication, not a new timing result. Local verification passed eight suites / 184 tests, the changed-code quality gate and compiled-composite actionlint. +## October 4 shared PR preflight capacity + +Static analysis and the unchanged compiler now share one ARM runner and guarded +Node 24 install. Static checks finish and all background work joins before the +compiler starts; unit planning still overlaps compilation. Each phase keeps its +classifier output. Successful no-op background bodies register every required +join when a phase is unselected or an earlier step failed. Unit and package +consumers depend on physical job success, including action cleanup. + +Three counterbalanced pairs in +[37180613601](https://github.com/stablyai/orca/actions/runs/37180613601) +used the same frozen checkout `f199a20c3acd`, Node 24.21.0, pnpm 12.8.1, +policy hashes, native cache hits, warm TypeScript cache and 10,787-file unit plan. +Both arms used the PR root-only download-store policy. Total active job time was +152 / 153 / 151 seconds separately and 138 / 133 / 129 combined. Excluding the +extra measurement-only evidence steps gives 151 / 151 / 149 versus +136 / 132 / 128 seconds: median 151 to 132, saving 19 seconds (12.6%). +Two heavy runner admissions become one. This saves capacity; it does not prove a +whole-PR latency or queue gain. The median active dependency barrier increases +from 116 to 132 seconds because compilation follows static checks. + +The separate physical-failure run +[37180755694](https://github.com/stablyai/orca/actions/runs/37180755694) +proved that an included TypeScript error failed the actual compiler, its planner +still joined, and unit/package admissions skipped. A registered late action post +failure also blocked both consumers after successful foreground checks and +published shards. All 12 unselected/prior-failure no-op backgrounds joined, and +the downstream audit passed. Local workflow contracts passed 239 tests across +12 suites; lint and formatting passed. + ## October 3 retired-cache collection observation The same owner-collection assertion failed in unit shard 3 of @@ -1912,6 +2078,47 @@ The source was restored afterward. Extra collection turns therefore preserve the strong-retention oracle. Hosted qualification is still required; these observations do not prove a particular VM-retention cause or quantify avoided retries. +## October 4 terminal oracle execution + +Three measured test-support changes preserve the original seeds, payloads, +chunk boundaries and meaningful assertions. Serializer comparisons reuse cells +and format only the first mismatch instead of allocating descriptors for every +cell. The terminal parity writer submits every original chunk in FIFO order and +awaits the final parser callback. The independent legacy frame oracle memoizes +measured code-point widths. Its discarded algebra-only case never called +production and still passed when production always threw. + +Three alternating one-worker hosted ARM pairs measured complete invocations: + +| Cohort | Baseline median | Candidate median | Saving | +| ---------------------------------------- | --------------- | ---------------- | ------ | +| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% | +| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% | +| Frame equivalence | 18.472s | 13.736s | 25.6% | + +[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) +retained 116 timed serializer passes and three existing/paired-control skips. +Separate captures matched all 190,796,645 raw bytes over 1,611 scenarios and +8,617 checkpoints (SHA256 `00ab219cfb31456af2ecd5e766d1b82d47abc751f6f2de0d7f795e36a936d3c7`), +including complete outputs and diagnostic payloads. Twenty candidate controls +passed; formatting/color/blank/clipping fault controls detected regressions. + +[37181073275](https://github.com/stablyai/orca/actions/runs/37181073275) +retained all 16 parity cases and default fuzz counts. Captures matched 2,325 +batches, 28,182 original chunks and 1,698,285 input bytes, with identical +terminal state and serialization per terminal/batch. Independent terminal +completion order differs, so comparison uses canonical per-terminal ordering +(SHA256 `c38ac1dbbefb9f6dc33ecfe7c495d65b707c1664614544622af93cfc1850e421`). +All 73 callback/parser/other-consumer controls passed; first-callback, reversed +chunks, missing empty boundary and early-completion faults failed. + +The frame candidate passed all 19 retained cases directly against the original +uncached legacy oracle, preserving 4,000 short and 800 near-cap seeded trials. +Sequence, surrogate width, byte width and span-transform faults failed real +assertions. A part-array alternative was rejected after adding time locally. +Hosted Node typecheck passed. These are focused workload savings, not measured +whole-shard or queue-delay improvements; application behavior is unchanged. + ## October 3 unit-selection evidence: include failed references The caller's `needs.test.result == 'success'` condition prevented the advisory @@ -1941,6 +2148,49 @@ remained red. Its review recognized all five shards as a complete reference (10,608 files, 8,965,977 worker-ms). This was again a full fallback with `selectionEvaluated: false`, not evidence for enabling selected tests. +## October 4 runtime imports and recovery fixtures + +Three helper-only tests now import the existing terminal modules directly rather +than initializing the runtime service. Ten copied-loop cases never exercised +runtime memoization: they passed with its cache, timestamp update or prune +invalidation disabled. Two actual helper checks remain. The existing runtime +prune suite now exercises real leaf cache reuse, split prompt timestamps, +ordinary output, fresh prompts and detection after retained-history eviction. +Each of those three production faults fails a real runtime assertion. + +Recovery tests now seed three exact fixture variants once, after the seed child +has closed. Each crash still receives an independent byte-for-byte copy of the +entire database/WAL family and remapped paths. Buffer.equals retains exact byte +comparison without recursive matcher overhead. All 46 original crash boundaries +and retries remain. Four additional copy-isolation/WAL checks run, and teardown +requires that all seed bytes remain unchanged after the full suite. + +Three alternating one-worker hosted ARM pairs in +[37182181976](https://github.com/stablyai/orca/actions/runs/37182181976) +measured these complete invocations: + +| Cohort | Baseline seconds | Candidate seconds | Median saving | +| --------------------------------- | ------------------------ | ------------------------ | ------------- | +| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% | +| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% | +| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% | + +The final runtime cohort has seven real cases versus 16 including the copied +loops; its new runtime case is included in candidate timing. Recovery has 50 +passes versus the original 46. Hosted Node typecheck passed. Recovery faults for +last-byte database/WAL corruption, shared database paths, missing WAL copies and +accepted/unaccepted seed collision failed the intended assertions. These are +focused workload savings, not measured whole-shard or queue-delay gains. + +An independent local cache screen left both caches disabled. Across 14 unchanged +files and 92 cases, a warm Vitest transform cache reduced median invocation time +3.090 to 1.948 seconds, excluding archive costs; its cold arm increased time to +3.281 seconds. Node compilation caching showed no gain. Controls reproduced stale +transforms after TypeScript configuration or plugin-option changes, so persisted +reuse requires a complete transform-input stamp and hosted net-cost evidence. +A separate 130,000-pane leaf-collection optimization was restored: its complete +migration-file timing stayed within noise. The regression fixture remains. + ## October 3 removal fixture cleanup ordering [37105566358](https://github.com/stablyai/orca/actions/runs/37105566358) @@ -1959,3 +2209,177 @@ same ordering assertion. The gate was released, both controls drained the captur job, and the instrumentation was removed. Changed-code quality passed. This proves the teardown ordering mechanism, not a measured avoided-retry saving. Final-head hosted qualification remains required. + +## October 4 store oracle and retention fixtures + +The randomized in-place-store test validated the copying oracle twice after +accepted mutations and compared snapshots through the same production parser. +Its 5,000-step retention fixture generated enough tombstones to hit the count +limit, but never reached the 4,096-revision age boundary. + +The test retains all four seeds and 1,500 mutations per seed, removes the duplicate +validation, and projects snapshots directly from the copying oracle's validated +maps. Separate fixtures now check the revision before, at and after expiry and +count overflow. Production code is unchanged. + +Three alternating one-worker pairs on `ubuntu-24.04-arm` in +[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) +measured baseline invocation times 33.551 / 33.304 / 33.529 seconds and candidate +13.848 / 13.816 / 13.875 seconds: median 33.529 to 13.848 seconds, saving 19.681 +seconds (58.7%). Baseline passed seven tests; candidate passed eight. This is a +focused test saving, not a measured whole-shard or queue-delay change. + +Hosted Node typecheck passed. Separate fault controls failed the intended +assertion for early, late and disabled age expiry, disabled count compaction, +and a snapshot that drops child descriptions. The description fault passes with +the original parser-sharing oracle and fails with the independent projection. + +## October 4 Git contention and remaining readiness waits + +The full Git admission benchmark compared a disabled arm with no correctness +assertions to an enabled arm with structural ledger checks. Its default CI test +now saturates the real base and headroom budgets with FIFO-gated child processes, +queues older background and newer interactive work, releases base slots, and +requires interactive priority, matching outputs and complete permit release. +The full original diagnostic remains opt-in through +`ORCA_GIT_ADMISSION_STORM_MEASUREMENT=1`; both opt-in tests passed locally. +The existing Windows real-Git parity tests remain unchanged; this fixture retains +its existing POSIX platform scope. + +Two remaining Antigravity transcript tests used real 5,000ms refusal windows. +They now use the existing scoped `waitForTranscriptIdle` timer harness after the +emulator drains. All 60 tests, original captured transcripts, deadlines and +readiness assertions remain. + +Three alternating one-worker hosted ARM pairs in +[37180614492](https://github.com/stablyai/orca/actions/runs/37180614492) +measured these complete focused invocations: + +| Suite | Baseline seconds | Candidate seconds | Median saving | +| --------------------- | ------------------------ | ------------------------ | --------------- | +| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) | +| Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) | + +Each candidate passed its original meaningful checks. Hosted Node typecheck +passed. Separate scheduler faults for bypassed admission, withheld release and +FIFO-only priority failed the queued-contention or interactive-start assertion. +Two additional local transcript faults failed the original picker-rejection and +repaint-readiness assertions. These are focused suite savings; whole-shard time +and queue delay were not measured by this experiment. + +## October 4 aggregate unit-test comparison + +A [counterbalanced hosted comparison](https://github.com/stablyai/orca/actions/runs/37197643399) +measured 128.605 seconds less summed test-process time (4.36%) and a 37.694-second +reduction in the slowest shard (5.98%). It compares the accepted optimizations +with their original file snapshots on the same source, five fixed shard +assignments, Node 24, Ubuntu ARM and four workers per process. This is one paired +trial, not a population estimate or a measurement of PR queue delay. + +| Test process | Original snapshots | Accepted optimizations | +| ----------------------------- | ------------------ | ---------------------- | +| Shard 1 | 574.221s | 533.116s | +| Shard 2 | 572.553s | 569.593s | +| Shard 3 | 630.629s | 592.935s | +| Shard 4 | 565.412s | 546.759s | +| Shard 5 | 609.410s | 581.218s | +| Sum: runner time during tests | 2952.225s | 2823.621s | +| Maximum: test critical path | 630.629s | 592.935s | + +Both arms cover exactly 10,838 timed modules on source `9574c8adb253` and tree +`4d5487e8b827`. One added eight-case batching qualification passes in a separate +0.770-second invocation outside the table, completing the 10,839-module ordinary +census. The complete timed case and outcome +comparison accounts for eight approved coverage changes: 105,231 original cases +versus 105,242 candidate cases. Removed copied simulations and an algebra-only +case are accompanied by real runtime, retention, recovery and reusable-cell +regressions. No unexpected case or outcome difference is accepted. + +Each arm starts with distinct empty transform and result caches; Node compilation +caching is disabled. Cold-cache execution ordering remains Vitest's default and +can change with source size. Source snapshots, assignments, raw reports and case outcomes +are checked. Only three case-title fields containing random temporary paths or +a UUID use stable identities, bound to the exact two test-source hashes; raw +titles remain in the artifacts. + +The five dependency setups total 84.284 seconds and are shared by both arms. +The actual paired jobs consumed 5,969 seconds and spanned 2,031 seconds from the +first start to the last completion. Those job figures include both treatments, +setup, uploads and staggered starts; they cannot be assigned to either arm or +used as a workflow saving. The table measures test-process wall time, not CPU +time or the complete CI workflow. Focused-suite percentages elsewhere in this +report are separate measurements and must not be summed into these results. + +The [earlier aggregate trial](https://github.com/stablyai/orca/actions/runs/37193799646) +is rejected because a real test failed; its timings do not qualify a gain. Two +local invocations sharing one XDG directory reproduced the Muse refresher failure. +The fixture now isolates and restores that setting in both arms. The historical +serializer case ledger was also independently corrected from the original source +before this fresh trial; its seven original cases and twenty candidate cases are +an explicit coverage change rather than an assumed equal census. + +## October 4 shard-weight holdouts + +Fresh shard weights were generated with the production importer from a complete +successful run of the accepted source. Two subsequent hosted holdouts used those +same weights and assignments without retraining. The +[first pair](https://github.com/stablyai/orca/actions/runs/37199891967) alternated +existing and fresh assignments across the five jobs; the +[second pair](https://github.com/stablyai/orca/actions/runs/37201939057) reversed +each job's treatment order. + +| Test-process measurement | First: existing | First: fresh | Reversed: existing | Reversed: fresh | +| ------------------------ | --------------- | ------------ | ------------------ | --------------- | +| Sum across five shards | 2813.595s | 2776.421s | 2924.689s | 2878.481s | +| Maximum shard wall | 578.735s | 584.709s | 603.995s | 614.408s | + +Fresh weights reduced summed test-process time by 1.32% and 1.58%, but increased +the slowest shard's time by 1.03% and 1.72%. The small capacity saving comes with +a repeated critical-path regression, so the existing weights remain. The 3.01% +improvement projected from training module durations is not a measured speed +gain. + +Every arm covers the same 10,839 modules and 105,250 case outcomes on source +`9574c8adb253`, tree `4d5487e8b827`, Node 24.21.0, Ubuntu ARM and four workers. +Both trials use cold caches and the same training data, weights, plans and case +identity rules. Complete raw reports, assignments, hashes and opposite treatment +orders are checked before combining the results. Two pairs supply no statistical +confidence or account-wide queue measurement. The second run's jobs started 119 +seconds apart; that stagger and the paired jobs' setup and upload costs are +separate from the treatment timings above. + +## October 4 remaining unit-test opportunities + +The audit retained real child-process, PTY, SSH and crash-boundary tests. It +removed copied simulations or algebra-only cases after fault controls showed +that they could pass with production behavior broken. The retained or replacement +tests exercise production behavior directly. The focused timings in this report +use the final qualified checks. They must not be added together to estimate a +whole-workflow saving. + +Several further changes did not justify promotion: + +- A synchronous readiness-clock screen retained all 49 shard cases and their + outcomes, but complete invocation time changed only from 34.210 to 33.518 + seconds in one local pair. That 2% result was too small to ship without a + stronger result; the original implementation remains. +- A larger local transform-cache screen reduced warm test execution. Separately + measured medians for warm tests (17.251 seconds), extraction (3.539 seconds) and + archive creation (3.092 seconds) sum to 23.882 seconds, versus 23.473 seconds + with caching disabled. This component estimate excludes transfer costs; it is not an + end-to-end measurement. Unkeyed plugin options, inherited configuration and + import priority also produced stale reuse. Persisted test transforms remain + disabled. +- Two successful full-run shadow references identified about 1.9% of + recorded worker time as omittable. That is advisory worker time, not measured + runner occupancy. It does not supply the failed-reference evidence or a + complete selected-run comparison needed to enable test selection. +- Six sampled failed PR runs contained no failed unit job that could trigger + unit-matrix fail-fast. Successful unit siblings of failures in other jobs + cannot be counted as savings from that policy. The sample is too small to + establish a population-wide rate, and the policy remains unchanged. + +These screens reject the examined changes; they do not establish that every +future optimization is exhausted. Shorter admitted jobs and one shared preflight +reduce demand on the existing runner allowance. They do not increase that +allowance or prove lower queue delay under different account traffic. diff --git a/docs/reference/deepseek-build-observation.md b/docs/reference/deepseek-build-observation.md new file mode 100644 index 00000000000..50686cf308d --- /dev/null +++ b/docs/reference/deepseek-build-observation.md @@ -0,0 +1,28 @@ +# DeepSeek Build terminal identity + +DeepSeek Build is the third-party [`innocarpe/deepseek-build`](https://github.com/innocarpe/deepseek-build) product, published as `@innocarpe/deepseek-build`. It is distinct from official DeepSeek Harness (`@deepseek-ai/dsh`), Reasonix, DSH Console and generic DeepSeek TUI wrappers. + +Orca recognizes manually started Build terminals through its existing process and title observations. `TerminalAgent` includes `dsb`; the launchable `TuiAgent` registry does not. No Build launcher, hook, readiness profile, resume command or history reader is registered. Existing generic terminal input remains available. + +The source and actual macOS release were checked at **v6.9.0**, source commit `74df67a56988e9a32845c4565cc62b021ea68c7d`. The darwin-arm64 release tarball SHA-256 is `a57f225a537fc5c027ac4592e3f37f7bdc28cc2d6e27a366934511ea565cb874`. + +- `package.json` publishes `dsb.js` and `deepseek-build.js` npm shims; the native child is `deepseek-build-agent`. +- `crates/dsb-cli/src/main.rs` separates the full-screen entry from `run`. Global value options such as `--cwd` can precede `run`; those invocations remain excluded from interactive recognition. +- `crates/dsb-cli/src/agent_launch.rs` emits the product OSC 0 title. The vendored pager's `notifications/title.rs` composes spinner, activity and product segments with ` - ` separators. +- The committed `dsb-6-9-0-folder` PTY fixture records the released binary's welcome screen and actual title in an isolated home and plain folder. It makes no successful-authentication or completed-model-turn claim. Its runtime test feeds raw chunks through `onPtyData` with foreground inspection unavailable. + +Explicit native owner markers retain their existing precedence. A Claude task merely mentioning Build is not a Build identity. Runtime publication reuses the existing optional `agentIdentity` string; no new RPC, stream opcode or status producer is added. Older hosts can omit identity, while older readers retain their existing unknown-agent handling. Execution-host process/title observations work without a Git repository; local source tests do not establish native Windows, Linux or SSH device coverage. + +For rendered proof, isolate both Electron and the actual PTY. On macOS, `login(1)` can replace the shell's inherited home. Test-only `ORCA_DISABLE_MACOS_LOGIN_SHELL=1` avoids that wrapper; do not change production launch policy for a proof. Require a nonce-bound file written by a helper executed in the spawned PTY, containing its actual `HOME`, `USERPROFILE`, `DEEPSEEK_BUILD_HOME`, `GROK_HOME` and trust-RPC flag, and verify it before agent launch. A terminal-text assertion can match command echo and is not isolation evidence. Explicit provider environment at the final execution boundary protects the test even after shell startup. + +The observation-type propagation and title/process recognition adapt Wooseong Kim's (`innocarpe`) [PR #23485](https://github.com/stablyai/orca/pull/23485), with source-backed corrections for the second npm shim and value options before `run`. Keep that predecessor open until a reviewed successor merges. + +Independent review follow-up: upstream 6.9.0 outer `Commands::Agent` forwards native PagerArgs options. Native `-p`/`--single` (alias `--print`), `--prompt-json` and `--prompt-file` are one-shot forms and are excluded from interactive process/foreground identity, including equals/compact short forms, npm wrappers and preceding value options. Positional interactive prompt text, native option values and the native `--` terminator stay distinct. Actual release native `--help` confirms exposed flags; source alias and forwarding are pinned above. + +Title follow-up confines Gemini identity/normalization and status sniffing before inspecting Build activity text. A verified Build title uses its leading own braille frame for working and leading `⚠ Action Required - ` for permission; embedded Gemini glyphs in activity/session/cwd text do not change its identity or status. Source-backed frame tests cover wrapped and alert variants, plus OSC input through the actual runtime/listing path. Native Gemini and other provider corpus contracts stay covered. + +Actual released outer `dsb agent -- --help` prints the native TUI help (`outer-forwarded-help.txt`), confirming Clap consumes the outer separator before forwarding. The observer distinguishes this from the native `--`: `dsb agent -- --print task` is one-shot, whereas `dsb agent -- -- --print` and direct native `-- --print` retain literal interactive prompt text. + +Native grammar follow-up: only the outer wrapper's `run` subcommand is one-shot. Native `deepseek-build-agent run`, forwarded `dsb agent run`, and `--leader-socket run` remain interactive; the last consumes `run` as a path value. Native `-c` is boolean, so Clap accepts `-cp task` and `-cptask` as continue plus single-turn prompt. Attached `-m`/`-r`/`-s`/`-w` values (including after `c`) do not expose a prompt flag. The released binary accepted the five review argument topologies with `--help` under an executed private-child environment assertion (`native-grammar-oracle.json`); this proves parsing/help, not successful model generation. + +Attached prompt values can begin with hyphens: native `-p-` and `-cp--print` consume `-` and `--print` as the single-turn prompt. The observer accepts the entire remainder after `p`, while the attached m/r/s/w value shields stay covered. The released native binary and outer `agent` wrapper accepted both forms with `--help` in a nonce-asserted private child (`attached-p-oracle.json`). diff --git a/docs/reference/jcode-hook-events.md b/docs/reference/jcode-hook-events.md deleted file mode 100644 index 5eaf0d52463..00000000000 --- a/docs/reference/jcode-hook-events.md +++ /dev/null @@ -1,163 +0,0 @@ -# jcode hook events - -What jcode actually emits, and why Orca's status mapping is shaped the way it is. -Everything below was captured from jcode **v0.87.1 (944f747e9)** by pointing every -`[hooks]` entry in `config.toml` at a script that appends `$JCODE_HOOK_PAYLOAD` to -a log, then running real turns. Re-capture before changing the mapping; do not -edit it from memory. - -## The six events - -jcode's `[hooks]` table (`crates/jcode-base/src/hooks.rs`) has six lifecycle -points. Five are **observers** — detached, fire-and-forget, they can never slow -the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input -to its stdin, and waits for it to exit before the tool runs. - -| Event | When | Orca state | Notable payload fields | -| --------------- | -------------------------------------------- | ---------- | --------------------------------------------------------- | -| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` | -| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` | -| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) | -| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` | -| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` | -| `session_end` | session closed | `done` | `source` = `close` | - -`session_start` is identity-only. jcode fires it on an idle TUI open, so mapping -it to `working` would spin before the user has typed anything (same reason Devin -does not map its `SessionStart`). - -## Captured payloads - -A `jcode run` turn that read one file and wrote another: - -```json -{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-haiku-4-5","session_id":"session_pawprint_1790149117838_071c2a106812396c","source":"create"} -{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"file_path\":\"sample.txt\",\"intent\":\"Read sample.txt to get its contents\"}","tool_name":"read"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"0","event":"post_tool","output_bytes":"12","session_id":"session_pawprint_…","status":"ok","tool_name":"read"} -{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"content\":\"HELLO\",\"file_path\":\"out.txt\",\"intent\":\"Write uppercased contents of sample.txt to out.txt\"}","tool_name":"write"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"9","event":"post_tool","output_bytes":"137","session_id":"session_pawprint_…","status":"ok","tool_name":"write"} -``` - -A TUI turn that failed upstream (note `turn_start`, which the `run` path does not emit): - -```json -{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-opus-5","session_id":"session_snail_…","source":"create"} -{"cwd":"/private/tmp/jcode-work","event":"turn_start","model":"claude-opus-5","session_id":"session_snail_…","source":"chat"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"6868","error":"Anthropic API error (503 Service Unavailable): …","event":"turn_end","model":"claude-opus-5","session_id":"session_snail_…","status":"error"} -``` - -Three consequences the mapping depends on: - -- **`turn_start` only fires on the streaming turn path** (TUI, desktop, swarm - workers, headless sessions), not `jcode run`. It is what fills the otherwise - blank window between a submitted prompt and the first tool call. -- **Only `pre_tool` carries `tool_input`.** `post_tool` reports the name and the - outcome, so the tool preview has to be held from the matching `pre_tool`. -- **Every jcode tool schema has an `intent` string** the model fills in. It is - the preview fallback when no tool-specific key (`file_path`, `command`, …) - matches. - -## Why Orca subscribes to the gate - -`pre_tool` is the only event that can report a tool *while it runs*. Without it a -three-minute `bash` shows no tool at all until it finishes. Two rules keep the -gate from ever costing the agent anything: - -1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits - for the process *and* reads its stderr to EOF — a backgrounded child that - inherited stderr would hold the gate open for as long as it ran. The managed - script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the - inherited pipes are closed and the script exits immediately. -2. **stdin is drained first.** jcode `write_all`s the full tool input to the - hook's stdin. A tool input larger than the pipe buffer (a big `write`) would - block that write until the gate timed out if nobody read it, so the script - drains stdin before any exit path. - -Orca never blocks a jcode tool call: the script always exits 0. - -## Questions and permissions - -jcode has **no interactive per-tool approval prompt**. Its safety model -(`crates/jcode-app-core/src/tool/bash_destructive_gate.rs`) either denies a -command outright or asks the model to justify it — both inside the tool, with no -human in the loop. There is therefore no hook, and no terminal-title state, for -"jcode is waiting on you" during ordinary tool use. - -The one tool a *human* answers is ambient mode's `request_permission` -(`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with -`jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the -tool input as the question card. `post_tool` for the same tool is *not* mapped — -by then the human has already answered. - -Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch, -bash, batch, bg, browser, compile_remote, conversation_search, edit, gmail, -integration_tools, ls, macos_computer_use, maintainer_feedback, mcp, memory, -multiedit, open, panel, patch, read, schedule, session_search, side_panel, -skill_manage, swarm, todo, webfetch, websearch, write` plus the ambient tools; -a substring rule over that set would be matching on coincidence. - -## Terminal titles - -jcode paints OSC 0 titles roughly once a second. Captured sequence from one TUI -session: - -``` -jcode → 🐍 jcode Snake → 🐍 jcode/creek Snake → 🌐 jcode Snake · work ~0s → … → 🌐 jcode Snake · last ~6s -``` - -The format is ` jcode [ · +N -M][ · work|last ~]` -(`crates/jcode-tui/src/tui/app/terminal_title.rs`). Orca uses it for tab-bar -identity only — status comes from hooks, never from a parsed title. Note there is -no "needs input" title state; that is the same gap as above, not an omission in -the parser. - -## Per-pane daemons - -jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire -*inside the daemon*. Every TUI client connects the daemon the first pane started, -so without isolation a second jcode pane's events carry the first pane's -`ORCA_PANE_KEY` and its status lands on the wrong tab. - -jcode does forward a client's terminal identity to hooks -(`CLIENT_TERMINAL_ENV_VARS` in `crates/jcode-terminal-launch/src/lib.rs`), but -that allowlist covers tmux/zellij/herdr and the terminal emulators — not -`ORCA_PANE_KEY`. Until it does, Orca stamps a per-pane `JCODE_RUNTIME_DIR` so -each pane gets its own daemon, socket, and lock. The value is a 16-hex hash of -the pane key because the socket path is capped at `SUN_LEN` (104 bytes) and a -full pane key never fits. - -## Windows hook launcher - -Use Jcode **v0.89.0 or newer** on Windows. Earlier observer hooks launch with -`DETACHED_PROCESS`, leaving their children without a console to inherit. A -console program such as the managed hook's `curl.exe` can then open a Windows -Terminal tab on every event. Jcode's launcher fix uses `CREATE_NO_WINDOW` for -observer hooks and the `pre_tool` gate, keeping their descendants invisible. -Changing the managed script alone cannot repair an older Jcode launcher. - -The managed Windows hook redirects its payload file into curl directly, avoiding -the extra shells that a `type ... | curl` pipeline starts. Existing managed scripts -are refreshed on Orca startup without changing the user's hook configuration. - -Report: https://github.com/stablyai/orca/pull/22539#issuecomment-5809618574 -Launcher fix: https://github.com/1jehuang/jcode/pull/1490 - -## Config shape - -`[hooks]` values accept a string or an array of strings (`HookCommands` in -`crates/jcode-config-types/src/lib.rs`), and jcode re-reads the config on reload, -so hooks can be added without restarting. jcode parses a hook command line -shell-style but **executes it directly, not through a shell** — the managed value -must be the script path, never an `if [ -f … ]` wrapper. - -That shell-style parse is `parse_hook_command` -(`crates/jcode-terminal-launch/src/lib.rs`), and it is why Orca stores the path -**shell-quoted**. The tokenizer splits on unquoted whitespace and consumes every -unquoted backslash as an escape, so a bare Windows path reaches `exec` as -`C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook fires at all; a POSIX home -with a space splits into two arguments. Single quotes pass a path through -verbatim — backslashes are literal inside them — so Orca single-quotes by -default and falls back to double quotes (escaping `\` and `"`) only for a path -that itself contains a single quote. The value is then TOML-quoted on the way -into the file, so neither the raw path nor the shell-quoted string appears -alone. diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index b2bcb3c40f4..c7720c68dce 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -111,6 +111,23 @@ provider, it puts `unsupported` on the wire and makes that host refuse its own. reply-schema fallback must never shape a param. Gate on the token instead, where the client decides what it is willing to do with an arm it does not know. +## Session search agent negotiation + +`aiVault.searchStatus` optionally advertises `supportedAgents`; current search clients +send their own `supportedAgents` with `aiVault.searchSessions`. These are string lists, +so a future provider name does not make a peer reject the capability reply. The client +narrows explicit agent filters to the host's list before calling its request parser. +The host narrows retrieval to the client's list before publishing a page. + +A peer without this field uses the frozen v1.4.211 search vocabulary. The existing +`supportsQoderHistory` flag proves CodeBuddy, ZCode, and Qoder support; +`supportsJcodeHistory` independently proves Jcode support. An explicit list takes +precedence over both flags. If only the status method is missing, the client still +searches the conservative legacy subset; other status errors propagate. Empty host +intersections keep the requested filters and use the existing no-match scope, so +consent, readiness, and unknown-scope results retain their normal precedence. +Local IPC advertises this build's full list, and every remote leg negotiates separately. + ## Enforcement `tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real diff --git a/docs/site/content/docs/model/worktrees.mdx b/docs/site/content/docs/model/worktrees.mdx index c90cf451351..52e432c4b6c 100644 --- a/docs/site/content/docs/model/worktrees.mdx +++ b/docs/site/content/docs/model/worktrees.mdx @@ -114,6 +114,8 @@ You can pin a worktree to the top of its project to keep long-running work in vi When a worktree has nested child worktrees (for example from orchestration or `worktree create` with a parent), the context menu can also offer **Sleep with Descendants (N)** and **Delete with Descendants…**. Sleep with descendants closes active panels on the selected workspace and every validated nested child in the same project, repo, and host — only workspaces with live terminals or browser tabs are targeted for sleep. Delete with descendants makes the existing cascading delete explicit. Stale lineage links, cycles, and children across host or repo boundaries are excluded. +A parent worktree shows its nested children under an **N children** chip; click the chip to show or hide them. To do the same from the keyboard, assign **Toggle Child Workspaces** under [Settings → Shortcuts](/docs/settings). It acts on the worktree under the pointer, or on the active worktree when nothing is hovered, and does exactly what that card's chip does. On a card with no chip of its own (it has no children, or a sidebar filter hides all of them), it uses the chip on the card's parent instead, and does nothing if there is none. + Double-click a worktree title in the sidebar to rename it inline. Double-clicking elsewhere on the card still opens the full edit dialog. In **Edit Worktree Details**, the issue field accepts **GitHub** or **Linear** (chip on the field; paste a URL to auto-detect). One linked issue per workspace — changing provider or clearing the field unlinks the previous one. For SSH workspaces whose host is disconnected, the card title row can show an inline reconnect control (see [SSH worktrees](/docs/ssh)). ## Resource Manager cleanup diff --git a/docs/site/content/docs/settings.mdx b/docs/site/content/docs/settings.mdx index 1bf6dc79434..4fac0564d48 100644 --- a/docs/site/content/docs/settings.mdx +++ b/docs/site/content/docs/settings.mdx @@ -133,6 +133,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then - Full keymap — every binding remappable. - Toggle Sleeping Workspaces ships unbound; assign it here if you want a direct shortcut for the sidebar sleeping-worktree filter. +- **Toggle Child Workspaces** ships unbound; assign it here to show or hide a parent worktree's nested children, the same as clicking its **N children** chip. It targets the hovered worktree, or the active one when nothing is hovered. - **Toggle Workspace Board** ships unbound; assign it here to open or close the Workspace Board with one shortcut. Existing bindings for `workspace.openBoard` continue to work. - Close all editor tabs defaults to `Cmd+Option+W` on macOS and `Ctrl+Alt+W` on Windows / Linux. - **Tab navigation defaults (new installs):** next/previous tab **across all types** is `Cmd+Shift+]` / `Cmd+Shift+[` (Ctrl on Linux/Windows). Same-type next/previous is `Cmd+Option+]` / `Cmd+Option+[`. Previous recent tab is `Ctrl+Tab`. Existing installs keep customized overrides under `~/.orca/keybindings.json`. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a899fd97f9f..ac23e12afba 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -13,7 +13,8 @@ import { import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ - '@streamparser/json', + 'stream-json', + 'stream-chain', '@xterm/headless', '@xterm/addon-serialize', 'tldts', diff --git a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json index 8784dc31168..d4adb3e24fd 100644 --- a/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json +++ b/mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json @@ -298,8 +298,8 @@ { "id": "inventory-lifecycle.unmount-before-1:settled", "observation": { - "sender": ["32c9018052ba", "2f1953cc0315"], - "payloads": ["8db0d1234c94", "c9b9d7a9b388"], + "sender": ["32c9018052ba"], + "payloads": ["8db0d1234c94"], "settlements": { "mount": "eb79a9b3682a", "old": "eb79a9b3682a", @@ -312,8 +312,8 @@ { "id": "inventory-lifecycle.unmount-before-1:remounted", "observation": { - "sender": ["32c9018052ba", "2f1953cc0315"], - "payloads": ["8db0d1234c94", "c9b9d7a9b388"], + "sender": ["32c9018052ba"], + "payloads": ["8db0d1234c94"], "settlements": { "mount": "eb79a9b3682a", "old": "eb79a9b3682a", diff --git a/mobile/src/mobile-web-shell/bridge-host-subscription-start-cleanup.test.ts b/mobile/src/mobile-web-shell/bridge-host-subscription-start-cleanup.test.ts new file mode 100644 index 00000000000..1f30232828f --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-host-subscription-start-cleanup.test.ts @@ -0,0 +1,217 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BRIDGE_MAX_MESSAGE_BYTES } from './bridge/bridge-caps' +import { createFakeRpcClient } from './bridge-host-test-fakes' +import { harness, ID, subscribeFrame } from './bridge-host-test-harness' +import { BRIDGE_MAX_UNACKED_FRAMES, BridgeHostSubscriptions } from './bridge-host-subscriptions' +import { + BridgeTerminalOutputBacklog, + TERMINAL_STREAM_ACK_SILENCE_MS, + type TerminalBacklogTimers +} from './bridge-terminal-output-backlog' + +const HELD_OUTPUT = { type: 'data', streamId: 1, chunk: 'h'.repeat(48 * 1024) } + +function manualTimers(): TerminalBacklogTimers & { + pending: () => number + delays: number[] + fire: () => void +} { + const handlers = new Map void>() + const delays: number[] = [] + let nextHandle = 0 + return { + set: (handler, ms) => { + nextHandle += 1 + handlers.set(nextHandle, handler) + delays.push(ms) + return nextHandle + }, + clear: (handle) => { + handlers.delete(handle) + }, + pending: () => handlers.size, + delays, + fire: () => { + for (const [handle, handler] of handlers) { + handlers.delete(handle) + handler() + } + } + } +} + +function emitWindowAndHold(onData: (payload: unknown) => void): void { + for (let index = 0; index < BRIDGE_MAX_UNACKED_FRAMES; index += 1) { + onData({ type: 'data', streamId: 1, chunk: String(index) }) + } + onData(HELD_OUTPUT) +} + +function observeBacklogs(): BridgeTerminalOutputBacklog[] { + const backlogs: BridgeTerminalOutputBacklog[] = [] + const hold = BridgeTerminalOutputBacklog.prototype.hold + vi.spyOn(BridgeTerminalOutputBacklog.prototype, 'hold').mockImplementation(function ( + this: BridgeTerminalOutputBacklog, + payload: unknown + ) { + if (!backlogs.includes(this)) { + backlogs.push(this) + } + return hold.call(this, payload) + }) + return backlogs +} + +afterEach(() => vi.restoreAllMocks()) + +describe('bridge subscription start cleanup', () => { + it('rethrows the original immediate failure without keeping a slot or timer', () => { + const failure = new Error('subscribe failed') + const timers = manualTimers() + const subscriptions = new BridgeHostSubscriptions({ + client: { + ...createFakeRpcClient(), + subscribe: () => { + throw failure + } + }, + post: vi.fn(), + onBinaryFrameDropped: vi.fn(), + terminalTimers: timers + }) + + expect(() => subscriptions.start(ID, 'terminal.subscribe', {})).toThrow(failure) + expect(subscriptions.size).toBe(0) + expect(timers.pending()).toBe(0) + expect(timers.delays).toEqual([]) + }) + + it('releases held output and its silence timer when subscribe emits and then throws', () => { + const failure = new Error('subscribe failed after output') + const timers = manualTimers() + const backlogs = observeBacklogs() + const subscriptions = new BridgeHostSubscriptions({ + client: { + ...createFakeRpcClient(), + subscribe: (_method, _params, onData) => { + emitWindowAndHold(onData) + expect(backlogs[0]?.pendingBytes).toBe(JSON.stringify(HELD_OUTPUT).length) + expect(timers.pending()).toBe(1) + throw failure + } + }, + post: vi.fn(), + onBinaryFrameDropped: vi.fn(), + terminalTimers: timers + }) + + let thrown: unknown + try { + subscriptions.start(ID, 'terminal.subscribe', {}) + } catch (error) { + thrown = error + } + expect(thrown).toBe(failure) + expect(subscriptions.size).toBe(0) + expect(backlogs).toHaveLength(1) + expect({ + pendingBytes: backlogs[0]?.pendingBytes, + held: backlogs[0]?.held, + liveTimers: timers.pending() + }).toEqual({ pendingBytes: 0, held: false, liveTimers: 0 }) + expect(timers.delays).toEqual([TERMINAL_STREAM_ACK_SILENCE_MS]) + }) + + it('answers the original failure and leaves a same-ID replacement alive past the old deadline', () => { + const client = createFakeRpcClient() + const timers = manualTimers() + let starts = 0 + const bridge = harness({ + ready: true, + terminalTimers: timers, + client: { + ...client, + subscribe: (method, params, onData, options) => { + starts += 1 + if (starts === 1) { + emitWindowAndHold(onData) + throw new Error('failed after output') + } + return client.subscribe(method, params, onData, options) + } + } + }) + + bridge.host.receive(subscribeFrame(ID)) + expect(bridge.last()).toMatchObject({ type: 'error', id: ID }) + bridge.host.receive(subscribeFrame(ID)) + expect(client.streams).toHaveLength(1) + timers.fire() + expect(client.streams[0]?.unsubscribes).toBe(0) + client.streams[0]?.emit({ type: 'data', streamId: 2, chunk: 'replacement still live' }) + expect(bridge.last()).toEqual({ + v: 1, + type: 'event', + id: ID, + seq: 1, + payload: { type: 'data', streamId: 2, chunk: 'replacement still live' } + }) + expect(bridge.frames().filter((frame) => frame.type === 'end')).toEqual([]) + bridge.host.dispose() + expect(client.streams[0]?.unsubscribes).toBe(1) + }) + + it('keeps a successful synchronous backlog until ack and unsubscribes once on cancel', () => { + const timers = manualTimers() + const backlogs = observeBacklogs() + const unsubscribe = vi.fn() + const subscriptions = new BridgeHostSubscriptions({ + client: { + ...createFakeRpcClient(), + subscribe: (_method, _params, onData) => { + emitWindowAndHold(onData) + return unsubscribe + } + }, + post: vi.fn(), + onBinaryFrameDropped: vi.fn(), + terminalTimers: timers + }) + + subscriptions.start(ID, 'terminal.subscribe', {}) + expect(subscriptions.has(ID)).toBe(true) + expect(backlogs[0]?.pendingBytes).toBe(JSON.stringify(HELD_OUTPUT).length) + expect(timers.pending()).toBe(1) + subscriptions.ack(ID, BRIDGE_MAX_UNACKED_FRAMES) + expect(backlogs[0]?.pendingBytes).toBe(0) + expect(timers.pending()).toBe(0) + subscriptions.cancel(ID, null) + subscriptions.cancel(ID, null) + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('preserves an overflow emitted before the original subscribe failure', () => { + const failure = new Error('failure after overflow') + const timers = manualTimers() + const post = vi.fn() + const subscriptions = new BridgeHostSubscriptions({ + client: { + ...createFakeRpcClient(), + subscribe: (_method, _params, onData) => { + onData('z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)) + throw failure + } + }, + post, + onBinaryFrameDropped: vi.fn(), + terminalTimers: timers + }) + + expect(() => subscriptions.start(ID, 'terminal.subscribe', {})).toThrow(failure) + expect(post.mock.calls).toEqual([ + [JSON.stringify({ v: 1, type: 'end', id: ID, reason: 'overflow' })] + ]) + expect(subscriptions.size).toBe(0) + expect(timers.pending()).toBe(0) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts index ab5430f15f6..e6f6847e6d9 100644 --- a/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts +++ b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts @@ -143,6 +143,7 @@ export class BridgeHostSubscriptions { ) } catch (error) { this.open.delete(id) + record.backlog?.dispose() throw error } // A stream that emitted and overflowed inside `subscribe` is already retired, and its diff --git a/mobile/src/platform/native-media-device.ts b/mobile/src/platform/native-media-device.ts index 23002b8901d..4d4c3552538 100644 --- a/mobile/src/platform/native-media-device.ts +++ b/mobile/src/platform/native-media-device.ts @@ -33,6 +33,14 @@ function stagedMediaFile(extension: string): FsFile { return new FsFile(Paths.cache, `orca-media-${Date.now()}-${Math.random()}.${extension}`) } +function discardUnreturnedMediaFile(file: FsFile): void { + try { + file.delete() + } catch { + // Best effort; the cache is the OS's to reclaim. + } +} + /** * Copies what a uri names into this shell's cache and answers the copy's uri. * @@ -57,11 +65,7 @@ export function copyPickedMediaIntoCache(uri: string): string { destination.write(new FsFile(uri).bytesSync()) } catch (error) { // The empty file this just created is nobody's otherwise: the caller never learns its name. - try { - destination.delete() - } catch { - // Best effort; the cache is the OS's to reclaim. - } + discardUnreturnedMediaFile(destination) throw error } return destination.uri @@ -94,7 +98,12 @@ export function nativeMediaDeviceDeps(registry: MediaHandleRegistry): NativeMedi stageBase64: (base64) => { const file = stagedMediaFile('png') file.create({ overwrite: true }) - file.write(base64, { encoding: 'base64' }) + try { + file.write(base64, { encoding: 'base64' }) + } catch (error) { + discardUnreturnedMediaFile(file) + throw error + } return file.uri }, openFile: (uri) => new FsFile(uri), diff --git a/mobile/src/platform/native-media-stage-cleanup.test.ts b/mobile/src/platform/native-media-stage-cleanup.test.ts new file mode 100644 index 00000000000..2391954c187 --- /dev/null +++ b/mobile/src/platform/native-media-stage-cleanup.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, afterEach, expect, it, vi } from 'vitest' + +type FileEvent = { + operation: 'create' | 'write' | 'delete' + uri: string + content?: string | Uint8Array + options?: { overwrite?: boolean; encoding?: string } +} + +type StorageState = { + files: Map + events: FileEvent[] + writeFailure: unknown + deleteFailure: unknown + createFailure: unknown + readFailure: unknown + partialBytes: number +} + +const storage = vi.hoisted(() => ({ + files: new Map(), + events: [], + writeFailure: null, + deleteFailure: null, + createFailure: null, + readFailure: null, + partialBytes: 0 +})) + +vi.mock('expo-clipboard', () => ({ + getImageAsync: () => Promise.resolve({ data: 'data:image/png;base64,AQID' }) +})) +vi.mock('expo-document-picker', () => ({})) +vi.mock('expo-image-picker', () => ({})) +vi.mock('expo-file-system', () => ({ + File: class { + readonly uri: string + + constructor(...parts: string[]) { + this.uri = parts.join('/') + } + + get size(): number { + return storage.files.get(this.uri)?.byteLength ?? 0 + } + + create(options: { overwrite?: boolean }): void { + storage.events.push({ operation: 'create', uri: this.uri, options }) + storage.files.set(this.uri, new Uint8Array()) + if (storage.createFailure !== null) { + throw storage.createFailure + } + } + + write(content: string | Uint8Array, options?: { encoding?: string }): void { + storage.events.push({ operation: 'write', uri: this.uri, content, options }) + if (storage.writeFailure !== null) { + storage.files.set(this.uri, new Uint8Array(storage.partialBytes)) + throw storage.writeFailure + } + storage.files.set( + this.uri, + typeof content === 'string' ? Uint8Array.from(Buffer.from(content, 'base64')) : content + ) + } + + bytesSync(): Uint8Array { + if (storage.readFailure !== null) { + throw storage.readFailure + } + return Uint8Array.from([9, 8]) + } + + delete(): void { + storage.events.push({ operation: 'delete', uri: this.uri }) + if (storage.deleteFailure !== null) { + throw storage.deleteFailure + } + storage.files.delete(this.uri) + } + }, + Paths: { cache: 'file:///cache' } +})) + +import { MediaHandleRegistry } from '../mobile-web-shell/media-handle-registry' +import { createNativeMediaVerbServer } from './native-media' +import { copyPickedMediaIntoCache, nativeMediaDeviceDeps } from './native-media-device' + +beforeEach(() => { + storage.files.clear() + storage.events.length = 0 + storage.writeFailure = null + storage.deleteFailure = null + storage.createFailure = null + storage.readFailure = null + storage.partialBytes = 0 + vi.spyOn(Date, 'now').mockReturnValue(1234) + vi.spyOn(Math, 'random').mockReturnValue(0.25) +}) + +afterEach(() => vi.restoreAllMocks()) + +function device() { + return nativeMediaDeviceDeps(new MediaHandleRegistry({ now: () => 0, discard: vi.fn() })) +} + +function thrownBy(operation: () => unknown): unknown { + try { + operation() + } catch (error) { + return error + } + throw new Error('Expected staging to throw') +} + +function heldBytes(): number { + return [...storage.files.values()].reduce((total, bytes) => total + bytes.byteLength, 0) +} + +it('preserves the successful staged URI, base64 options and bytes without deleting it', () => { + const uri = device().stageBase64('AQID') + expect(uri).toBe('file:///cache/orca-media-1234-0.25.png') + expect(storage.events).toEqual([ + { operation: 'create', uri, options: { overwrite: true } }, + { operation: 'write', uri, content: 'AQID', options: { encoding: 'base64' } } + ]) + expect(storage.files.get(uri)).toEqual(Uint8Array.from([1, 2, 3])) +}) + +it.each([0, 4096])('releases an unreturned file after a write fails with %i bytes', (bytes) => { + const original = new Error('partial native write') + storage.writeFailure = original + storage.partialBytes = bytes + expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original) + expect(heldBytes()).toBe(0) + expect(storage.files.size).toBe(0) + expect(storage.events.at(-1)).toEqual({ + operation: 'delete', + uri: 'file:///cache/orca-media-1234-0.25.png' + }) +}) + +it('preserves the write failure when best-effort deletion also fails', () => { + const original = new Error('native write failed') + storage.writeFailure = original + storage.deleteFailure = new Error('cache cannot be deleted') + storage.partialBytes = 4096 + expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original) + expect(storage.events.map((event) => event.operation)).toEqual(['create', 'write', 'delete']) + expect(heldBytes()).toBe(4096) +}) + +it('preserves creation failure and its original operation order', () => { + const original = new Error('native create failed') + storage.createFailure = original + expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original) + expect(storage.events.map((event) => event.operation)).toEqual(['create']) +}) + +it('deletes only the failed file while a prior successful file remains readable', () => { + vi.spyOn(Math, 'random').mockReturnValueOnce(0.1).mockReturnValueOnce(0.2) + const successfulUri = device().stageBase64('AQID') + const original = new Error('next write failed') + storage.writeFailure = original + storage.partialBytes = 4096 + expect(thrownBy(() => device().stageBase64('BAUG'))).toBe(original) + expect(storage.events.filter((event) => event.operation === 'delete')).toEqual([ + { operation: 'delete', uri: 'file:///cache/orca-media-1234-0.2.png' } + ]) + expect([...storage.files.keys()]).toEqual([successfulUri]) + expect(storage.files.get(successfulUri)).toEqual(Uint8Array.from([1, 2, 3])) + expect(heldBytes()).toBe(3) +}) + +it('keeps the clipboard verb rejection while leaving no unregistered cache bytes', async () => { + const original = new Error('clipboard write failed') + storage.writeFailure = original + storage.partialBytes = 4096 + const registryDiscard = vi.fn() + const registry = new MediaHandleRegistry({ now: () => 0, discard: registryDiscard }) + const serve = createNativeMediaVerbServer(nativeMediaDeviceDeps(registry)) + await expect(serve('native.media.pick', { source: 'clipboard', multiple: false })).rejects.toBe( + original + ) + expect(registry.liveCount()).toBe(0) + expect(registryDiscard).not.toHaveBeenCalled() + expect(heldBytes()).toBe(0) + expect(storage.files.size).toBe(0) +}) + +it('retains the provider-copy cleanup and original read error when deletion fails', () => { + const original = new Error('provider read failed') + storage.readFailure = original + storage.deleteFailure = new Error('cache delete failed') + expect(thrownBy(() => copyPickedMediaIntoCache('content://media/1'))).toBe(original) + expect(storage.events).toEqual([ + { + operation: 'create', + uri: 'file:///cache/orca-media-1234-0.25.bin', + options: { overwrite: true } + }, + { operation: 'delete', uri: 'file:///cache/orca-media-1234-0.25.bin' } + ]) +}) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 67e90132866..c8193641fef 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -3,6 +3,8 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea import { afterEach, describe, expect, it, vi } from 'vitest' import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows' +import { colors } from '../theme/mobile-theme' vi.mock('react-native', async () => { const React = await import('react') @@ -60,6 +62,7 @@ describe('MobileNativeChatMessage', () => { function render( message: NativeChatMessage, props: { + fontScale?: number toolsExpanded?: boolean structuredActivityUi?: boolean activeTurnIsWorking?: boolean @@ -81,6 +84,38 @@ describe('MobileNativeChatMessage', () => { const textIn = (node: ReactTestInstance): string[] => node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) + it.each(['system', 'user'] as const)( + 'renders a %s host notice as selectable muted text rather than a markdown answer', + (role) => { + const tree = render( + { + id: 'notice', + role, + timestamp: 1, + blocks: [ + { type: 'text', text: 'provider fallback', presentation: 'history-item-too-large' } + ] + }, + { fontScale: 1.5 } + ) + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) + const text = tree.root.find((node) => String(node.type) === 'Text') + expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large']) + expect(text.props.selectable).toBe(true) + expect(Object.assign({}, ...text.props.style)).toMatchObject({ + color: colors.textMuted, + fontSize: 25.5 + }) + } + ) + + it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => { + const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }])) + expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe( + 'provider fallback' + ) + }) + it('renders a loadable preview URI as an image thumbnail', () => { const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }])) const image = tree.root.findByType('Image' as never) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 9c894e3cd7f..40635166281 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -4,6 +4,10 @@ import { Image, Text as NativeText, View } from 'react-native' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' +import { + AGENT_SESSION_HOST_STATUS_COPY, + isAgentSessionHostStatusPresentation +} from '../../../src/shared/agent-session-host-status-rows' import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileMarkdown } from '../components/MobileMarkdown' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' @@ -24,6 +28,13 @@ function Prose({ onOpenFile?: (relativePath: string) => void }): React.JSX.Element | null { if (isTextBlock(block)) { + if (isAgentSessionHostStatusPresentation(block.presentation)) { + return ( + + {AGENT_SESSION_HOST_STATUS_COPY[block.presentation]} + + ) + } // Inverted (user) bubbles use a fixed dark-on-light text rather than the // markdown renderer's light-on-dark palette. if (invert) { diff --git a/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx b/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx new file mode 100644 index 00000000000..3cecdb9ac0a --- /dev/null +++ b/mobile/src/session/mobile-file-search-owner-cleanup.test.tsx @@ -0,0 +1,280 @@ +import { createElement, StrictMode, type ReactNode } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Image: 'Image', + Text: 'Text', + TextInput: 'TextInput', + View: 'View', + Pressable: 'Pressable', + Keyboard: { dismiss: vi.fn() }, + ScrollView: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('ScrollView', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + } +}) +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronLeft: 'ChevronLeft', + ChevronRight: 'ChevronRight', + ImagePlus: 'ImagePlus', + Mic: 'Mic', + Square: 'Square', + X: 'X' +})) +vi.mock('../components/BottomDrawer', async () => { + const React = await import('react') + return { + BottomDrawer: ({ visible, children }: { visible: boolean; children?: ReactNode }) => + visible ? React.createElement('BottomDrawer', { visible }, children) : null + } +}) + +type Reply = Awaited> +const missing: Reply = { + id: 'search', + ok: false, + error: { code: 'method_not_found', message: 'legacy host' }, + _meta: { runtimeId: 'legacy' } +} +const inventory: Reply = { + id: 'list', + ok: true, + result: { files: [{ relativePath: 'src/apple.ts' }] }, + _meta: { runtimeId: 'legacy' } +} +const searchCall = [ + 'files.searchPaths', + { + worktree: 'id:folder:remote', + query: 'apple', + limit: 16 + } +] +function pendingReply() { + let resolve: (reply: Reply) => void = () => { + throw new Error('uninitialized reply') + } + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} +function mount(sendRequest: RpcClient['sendRequest'], strict = false, getGeneration = () => 1) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The actual hook only reads these two RPC members at the fake transport boundary. + const client = { sendRequest, getGeneration } as RpcClient + let search: ReturnType | undefined + function Route({ showChat }: { showChat: boolean }) { + search = useMobileNativeChatFileSearch({ client, worktreeId: 'folder:remote' }) + return showChat + ? createElement(MobileNativeChatComposer, { + value: '@apple', + onChangeText: vi.fn(), + onSend: async () => false, + sendSurfaceId: 'remote-chat', + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => 0, + filePaths: search.nativeChatFilePaths, + onNeedFiles: search.loadNativeChatFiles + }) + : null + } + const element = (showChat: boolean) => + strict + ? createElement(StrictMode, null, createElement(Route, { showChat })) + : createElement(Route, { showChat }) + let renderer: ReturnType | undefined + act(() => { + renderer = create(element(false)) + }) + if (!renderer || !search) { + throw new Error('actual route hook did not mount') + } + const view = renderer + return { + query: () => + act(() => { + search?.loadNativeChatFiles('apple') + }), + showChat: () => + act(() => { + view.update(element(true)) + }), + triggerComposer: () => + act(() => { + view.root + .find((node) => typeof node.props.onSelectionChange === 'function') + .props.onSelectionChange({ nativeEvent: { selection: { end: 6 } } }) + }), + hideChat: () => + act(() => { + view.update(element(false)) + }), + paths: () => search?.nativeChatFilePaths, + dispose: () => + act(() => { + view.unmount() + }) + } +} +async function debounce(): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(120) + }) +} +async function refuse(search: ReturnType): Promise { + await act(async () => { + search.resolve(missing) + await search.promise + }) +} +beforeEach(() => { + vi.useFakeTimers() +}) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +it.each([false, true])( + 'a disposed actual composer owner admits no new inventory; StrictMode=%s', + async (strict) => { + const search = pendingReply() + const send = vi.fn((method) => + method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory) + ) + const view = mount(send, strict) + view.showChat() + view.triggerComposer() + await debounce() + expect(send.mock.calls).toEqual([searchCall]) + view.dispose() + expect(vi.getTimerCount()).toBe(0) + await refuse(search) + expect(send.mock.calls.filter(([method]) => method === 'files.searchPaths')).toHaveLength(1) + expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(0) + } +) + +it.each([false, true])( + 'removing only the composer retains live fallback and cache; StrictMode=%s', + async (strict) => { + const search = pendingReply() + const send = vi.fn((method) => + method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory) + ) + const view = mount(send, strict) + view.showChat() + view.triggerComposer() + await debounce() + expect(send.mock.calls).toEqual([searchCall]) + view.hideChat() + await refuse(search) + expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]]) + expect(view.paths()).toEqual(['src/apple.ts']) + view.showChat() + view.triggerComposer() + await debounce() + expect(view.paths()).toEqual(['src/apple.ts']) + expect(send).toHaveBeenCalledTimes(2) + view.dispose() + } +) + +it('64 retired actual hooks do not start 64 whole-workspace reads', async () => { + const pending = Array.from({ length: 64 }, pendingReply) + let index = 0 + const send = vi.fn((method) => + method === 'files.searchPaths' ? pending[index++]!.promise : Promise.resolve(inventory) + ) + for (let owner = 0; owner < pending.length; owner++) { + const view = mount(send) + view.query() + await debounce() + view.dispose() + } + expect(send.mock.calls).toEqual(pending.map(() => searchCall)) + expect(vi.getTimerCount()).toBe(0) + await act(async () => { + pending.forEach((reply) => reply.resolve(missing)) + await Promise.all(pending.map((reply) => reply.promise)) + }) + expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(0) +}) + +it.each([false, true])( + 'a same-client successor owns its live fallback; StrictMode=%s', + async (strict) => { + const retiredSearch = pendingReply() + let searches = 0 + const send = vi.fn((method) => + method === 'files.searchPaths' + ? ++searches === 1 + ? retiredSearch.promise + : Promise.resolve(missing) + : Promise.resolve(inventory) + ) + const retired = mount(send, strict) + retired.query() + await debounce() + retired.dispose() + const successor = mount(send, strict) + successor.query() + await debounce() + expect(successor.paths()).toEqual(['src/apple.ts']) + expect(send.mock.calls).toEqual([ + searchCall, + searchCall, + ['files.list', { worktree: 'id:folder:remote' }] + ]) + await refuse(retiredSearch) + expect(successor.paths()).toEqual(['src/apple.ts']) + expect(send.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1) + successor.dispose() + } +) + +it('reentrant disposal after fallback entry preserves the admitted inventory', async () => { + const search = pendingReply() + let disposal: (() => void) | undefined + const send = vi.fn((method) => + method === 'files.searchPaths' ? search.promise : Promise.resolve(inventory) + ) + const view = mount(send, false, () => { + disposal?.() + return 1 + }) + view.query() + await debounce() + expect(send.mock.calls).toEqual([searchCall]) + disposal = view.dispose + await refuse(search) + expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]]) +}) + +it('already admitted inventory still settles after full owner disposal', async () => { + const list = pendingReply() + const send = vi.fn((method) => + method === 'files.searchPaths' ? Promise.resolve(missing) : list.promise + ) + const view = mount(send) + view.query() + await debounce() + expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]]) + view.dispose() + await act(async () => { + list.resolve(inventory) + await list.promise + }) + expect(send.mock.calls).toEqual([searchCall, ['files.list', { worktree: 'id:folder:remote' }]]) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 51ff9ab1f36..225d664e65e 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -29,6 +29,11 @@ export const styles = StyleSheet.create({ lineHeight: TEXT_SIZE + 6, fontWeight: '500' }, + hostNotice: { + color: colors.textMuted, + fontSize: TEXT_SIZE, + lineHeight: TEXT_SIZE + 6 + }, reasoning: { opacity: 0.7 }, diff --git a/mobile/src/session/mobile-native-chat-pair-window.test.ts b/mobile/src/session/mobile-native-chat-pair-window.test.ts new file mode 100644 index 00000000000..fb2bd15605c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pair-window.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' + +function row(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage { + return { id, role, source: 'transcript', timestamp: 1, blocks: [{ type: 'text', text: id }] } +} + +function pair(id: string): NativeChatMessage[] { + return [row(`${id}:reasoning`, 'reasoning'), row(id)] +} + +describe('mobile complete-pair stream windows', () => { + it.each(['appended', 'snapshot'])( + 'keeps a pair at the %s cutoff without moving the cursor', + (type) => { + const merger = createNativeChatMerger() + const base = [ + ...pair('opencode:oldest'), + ...Array.from({ length: 38 }, (_, i) => row(`m${i}`)) + ] + replaceList(merger, base) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type, + messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')], + hasMore: true, + beforeOffset: 1 + }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] }) + expect(merger.indexById.get('opencode:oldest:reasoning')).toBe(0) + } + ) + + it.each(['appended', 'snapshot'])( + 'keeps reasoning, answer and omission from one raw row at the %s cutoff', + (type) => { + const group = [...pair('opencode:oldest'), row('opencode:oldest:omission', 'system')].map( + (message) => ({ ...message, transcriptOffset: 7 }) + ) + const base = [...group, ...Array.from({ length: 38 }, (_, i) => row(`m${i}`))] + const merger = createNativeChatMerger() + replaceList(merger, base) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type, + messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')], + hasMore: true, + beforeOffset: 7 + }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] }) + expect(merger.list.slice(0, 3)).toEqual(group) + expect(merger.list).toHaveLength(42) + } + ) + + it('invalidates the cursor when the oldest complete pair leaves', () => { + const merger = createNativeChatMerger() + const base = [...pair('opencode:oldest'), ...Array.from({ length: 39 }, (_, i) => row(`m${i}`))] + replaceList(merger, base) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [row('latest')] }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toEqual({ + kind: 'messages', + messages: [...base.slice(2), row('latest')], + cursorInvalidated: true + }) + expect(merger.indexById.has('opencode:oldest')).toBe(false) + expect(merger.indexById.has('opencode:oldest:reasoning')).toBe(false) + }) + + it('retains a host page with an extra pair row across a user append followed by a pair append', () => { + const merger = createNativeChatMerger() + const base = [ + ...pair('opencode:first'), + ...Array.from({ length: 13 }, (_, i) => [ + row(`user-${i}`, 'user'), + ...pair(`opencode:${i}`) + ]).flat() + ] + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: base, hasMore: true, beforeOffset: 123 }, + limit: 40, + replaceSnapshot: true + }) + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [row('latest-user', 'user')] }, + limit: 40, + replaceSnapshot: false + }) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: pair('opencode:latest') }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toMatchObject({ kind: 'messages', cursorInvalidated: true }) + expect(merger.list).toHaveLength(41) + expect(merger.list.slice(0, 2)).toEqual(pair('opencode:0')) + expect(merger.list.slice(-2)).toEqual(pair('opencode:latest')) + }) + + it('keeps legacy peers with independent reasoning IDs at the raw row limit', () => { + const merger = createNativeChatMerger() + replaceList(merger, [row('claude-thinking', 'reasoning'), row('claude-answer')]) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [row('latest')] }, + limit: 2, + replaceSnapshot: false + }) + expect(result).toEqual({ + kind: 'messages', + messages: [row('claude-answer'), row('latest')], + cursorInvalidated: true + }) + expect(merger.list).toHaveLength(2) + }) +}) diff --git a/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx new file mode 100644 index 00000000000..3ba4220962b --- /dev/null +++ b/mobile/src/session/mobile-session-toast-owner-cleanup.test.tsx @@ -0,0 +1,434 @@ +import { createElement, StrictMode } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' + +type AnimationStart = { + options: { toValue: number; duration: number; useNativeDriver: boolean } + complete: (result: { finished: boolean }) => void +} +const boundary = vi.hoisted(() => { + const animationStarts: AnimationStart[] = [] + return { + animationStarts, + writeText: vi.fn<(text: string) => Promise>(), + success: vi.fn(), + error: vi.fn(), + synchronous: false + } +}) +vi.mock('react-native', () => ({ + View: 'View', + Platform: { OS: 'ios' }, + Keyboard: { dismiss: vi.fn() }, + Animated: { + timing: ( + _value: unknown, + options: { toValue: number; duration: number; useNativeDriver: boolean } + ) => ({ + start: (complete: (result: { finished: boolean }) => void) => { + boundary.animationStarts.push({ options, complete }) + if (boundary.synchronous) { + complete({ finished: true }) + } + } + }) + } +})) +vi.mock('../platform/clipboard', () => ({ + useClipboardWriter: () => ({ writeText: boundary.writeText }), + useClipboardReader: () => ({ contents: async () => ({ text: false, image: false }) }) +})) +vi.mock('../platform/haptics', () => ({ + triggerSuccess: boundary.success, + triggerError: boundary.error, + triggerSelection: vi.fn(), + triggerEdgeBump: vi.fn() +})) +vi.mock('../terminal/terminal-copy-gutter-preference', () => ({ + useTerminalCopyTrimsGutter: () => ({ current: false }) +})) +vi.mock('./mobile-session-styles', () => ({ styles: { container: {}, kavInner: {} } })) +vi.mock('./MobileSessionHeader', () => ({ MobileSessionHeader: () => null })) +vi.mock('./MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null })) +vi.mock('./MobileSessionSheets', () => ({ MobileSessionSheets: () => null })) +import { useMobileSessionFeedbackCapabilities } from './use-mobile-session-feedback-capabilities' +import { useMobileSessionAccessorySelection } from './use-mobile-session-accessory-selection' +import { MobileSessionSurface } from './MobileSessionSurface' + +type FeedbackScope = Parameters[0] +type AccessoryScope = Parameters[0] +type SurfaceController = Parameters[0]['controller'] + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.clearAllMocks() + boundary.animationStarts.length = 0 + boundary.synchronous = false + vi.restoreAllMocks() +}) +function mounted(strict = false) { + const setToastMessage = vi.fn() + const clearTerminalCache = vi.fn() + const cancelSelect = vi.fn() + const scope = { + client: null, + connState: 'connected', + initialCreateWarning: '', + sessionTabs: [], + sessionTabsRef: { current: [] }, + activeSessionTabId: null, + activeSessionTabIdRef: { current: null }, + markdownDocs: new Map(), + markdownDocsRef: { current: new Map() }, + createWarningState: { source: '', visible: '' }, + setCreateWarningState: vi.fn(), + setToastMessage, + toastOpacityRef: { current: {} }, + toastHideTimerRef: { current: null }, + toastSeqRef: { current: 0 }, + clientRef: { current: null }, + connStateRef: { current: 'connected' }, + activeSessionTabTypeRef: { current: 'terminal' }, + delayedActionTimersRef: { current: new Set() }, + activeSessionTab: { type: 'terminal' }, + worktreeId: 'folder:remote', + isFloatingWorkspaceRoute: false, + setTerminalKeyboardMetrics: vi.fn(), + setSelectModeActive: vi.fn(), + setCanPaste: vi.fn(), + ptyModesRef: { current: new Map() }, + initialModesSeenRef: { current: new Set() }, + terminalRefs: { current: new Map([['handle', { cancelSelect }]]) }, + liveInputFocusTimerRef: { current: null }, + sessionTabActionSheetRequestSeqRef: { current: 0 }, + activeHandleRef: { current: 'handle' }, + clearPendingLiveInputCommit: vi.fn(), + clearTerminalCache, + handleAccessoryKey: vi.fn(), + clearSessionTabActionSheetKeyboardListener: vi.fn() + } + const read: { + value: + | (ReturnType & + ReturnType) + | undefined + } = { value: undefined } + function Probe({ + surfaceKey = 'initial', + visible = true + }: { + surfaceKey?: string + visible?: boolean + }) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual feedback hook reads only the enumerated members; its type includes unrelated composed controller hooks. + const feedbackScope = scope as unknown as FeedbackScope + const feedback = useMobileSessionFeedbackCapabilities(feedbackScope) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual accessory hook reads only enumerated members plus actual feedback capability callbacks. + const accessoryScope = { ...scope, ...feedback } as unknown as AccessoryScope + const accessory = useMobileSessionAccessorySelection(accessoryScope) + const result = { ...feedback, ...accessory } + read.value = result + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Actual surface reads only setMobileSessionRootRef; its child components are isolated presentation stubs. + const controller = result as unknown as SurfaceController + return visible ? createElement(MobileSessionSurface, { key: surfaceKey, controller }) : null + } + let root: ReturnType | undefined + const element = (surfaceKey = 'initial', visible = true) => + strict + ? createElement(StrictMode, null, createElement(Probe, { surfaceKey, visible })) + : createElement(Probe, { surfaceKey, visible }) + act(() => { + root = create(element(), { createNodeMock: () => ({}) }) + }) + if (!root || !read.value) { + throw new Error('real composed hooks did not mount') + } + const renderer = root + return { + root, + get api() { + if (!read.value) { + throw new Error('real composed hooks did not render') + } + return read.value + }, + scope, + setToastMessage, + clearTerminalCache, + cancelSelect, + rerender: (surfaceKey: string, visible = true) => + act(() => renderer.update(element(surfaceKey, visible))) + } +} +it.each([ + ['success', false], + ['failure', false], + ['success', true], + ['failure', true] +] as const)( + 'keeps admitted %s copy outcome after unmount without new timers, StrictMode=%s', + async (outcome, strict) => { + vi.useFakeTimers() + let resolveWrite: (() => void) | undefined + let rejectWrite: ((error: Error) => void) | undefined + const clipboard = new Promise((resolve, reject) => { + resolveWrite = resolve + rejectWrite = reject + }) + boundary.writeText.mockReturnValue(clipboard) + const view = mounted(strict) + const baselineClear = view.clearTerminalCache.mock.calls.length + const baselineSequence = view.scope.toastSeqRef.current + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let copying: Promise | undefined + act(() => { + copying = view.api.handleSelectionCopy('handle', 'original text') + }) + expect(boundary.writeText.mock.calls).toEqual([['original text']]) + expect(boundary.animationStarts).toHaveLength(0) + act(() => view.root.unmount()) + expect(view.clearTerminalCache).toHaveBeenCalledTimes(baselineClear + 1) + expect(view.scope.toastSeqRef.current).toBe(baselineSequence + 1) + expect(vi.getTimerCount()).toBe(0) + await act(async () => { + if (outcome === 'success') { + resolveWrite?.() + } else { + rejectWrite?.(new Error('clipboard refused')) + } + await copying + }) + expect(boundary.success).toHaveBeenCalledTimes(outcome === 'success' ? 1 : 0) + expect(boundary.error).toHaveBeenCalledTimes(outcome === 'failure' ? 1 : 0) + expect(view.cancelSelect).toHaveBeenCalledTimes(outcome === 'success' ? 1 : 0) + expect(warn.mock.calls).toEqual( + outcome === 'failure' + ? [['[mobile-clip] setString failed', { name: 'Error', message: 'clipboard refused' }]] + : [] + ) + for (const animation of boundary.animationStarts) { + act(() => animation.complete({ finished: true })) + } + expect(vi.getTimerCount()).toBe(0) + expect(boundary.animationStarts).toHaveLength(0) + expect(view.setToastMessage).not.toHaveBeenCalled() + } +) +it('proves existing detach sequence already fences an admitted fade-in', () => { + vi.useFakeTimers() + const view = mounted() + act(() => view.api.showToast('already visible')) + expect(boundary.animationStarts).toHaveLength(1) + act(() => view.root.unmount()) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(0) + expect(boundary.animationStarts).toHaveLength(1) +}) +it('proves existing detach cleanup clears a live hide timer', () => { + vi.useFakeTimers() + const view = mounted() + act(() => view.api.showToast('already visible')) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => view.root.unmount()) + expect(vi.getTimerCount()).toBe(0) +}) + +it.each([false, true])( + 'preserves late admitted copy after same-hook root replacement, StrictMode=%s', + async (strict) => { + vi.useFakeTimers() + let finish: (() => void) | undefined + boundary.writeText.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const view = mounted(strict) + const originalShow = view.api.showToast + let copying: Promise | undefined + act(() => { + copying = view.api.handleSelectionCopy('handle', 'original text') + }) + const previousClear = view.clearTerminalCache.mock.calls.length + view.rerender('replacement') + expect(view.clearTerminalCache.mock.calls.length).toBeGreaterThan(previousClear) + expect(view.api.showToast).toBe(originalShow) + await act(async () => { + finish?.() + await copying + }) + expect(boundary.writeText.mock.calls).toEqual([['original text']]) + expect(boundary.success).toHaveBeenCalledTimes(1) + expect(view.cancelSelect).toHaveBeenCalledTimes(1) + expect(view.setToastMessage.mock.calls).toEqual([['Copied']]) + expect(boundary.animationStarts).toHaveLength(1) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => view.root.unmount()) + expect(vi.getTimerCount()).toBe(0) + } +) +it('preserves late copy while surface is temporarily detached but hook stays mounted', async () => { + vi.useFakeTimers() + let finish: (() => void) | undefined + boundary.writeText.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + const view = mounted() + let copying: Promise | undefined + act(() => { + copying = view.api.handleSelectionCopy('handle', 'original text') + }) + view.rerender('inactive', false) + expect(view.clearTerminalCache).toHaveBeenCalledTimes(1) + await act(async () => { + finish?.() + await copying + }) + expect(boundary.success).toHaveBeenCalledTimes(1) + expect(view.cancelSelect).toHaveBeenCalledTimes(1) + expect(view.setToastMessage.mock.calls).toEqual([['Copied']]) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + view.rerender('active', true) + act(() => view.root.unmount()) + expect(vi.getTimerCount()).toBe(0) +}) +it.each([1200, 1500])('keeps exact live fade-in/hide/fade-out timing at %s ms', (duration) => { + vi.useFakeTimers() + const view = mounted() + act(() => view.api.showToast('message', duration)) + expect(view.setToastMessage.mock.calls).toEqual([['message']]) + expect(boundary.animationStarts[0].options).toEqual({ + toValue: 1, + duration: 150, + useNativeDriver: true + }) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => { + vi.advanceTimersByTime(duration - 1) + }) + expect(boundary.animationStarts).toHaveLength(1) + act(() => { + vi.advanceTimersByTime(1) + }) + expect(vi.getTimerCount()).toBe(0) + expect(boundary.animationStarts[1].options).toEqual({ + toValue: 0, + duration: 200, + useNativeDriver: true + }) + act(() => boundary.animationStarts[1].complete({ finished: true })) + expect(view.setToastMessage.mock.calls).toEqual([['message'], [null]]) + act(() => view.root.unmount()) +}) +it('preserves old unfinished/replaced animation fences and latest toast ownership', () => { + vi.useFakeTimers() + const view = mounted() + act(() => view.api.showToast('first')) + act(() => view.api.showToast('second', 1500)) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(0) + act(() => boundary.animationStarts[1].complete({ finished: false })) + expect(vi.getTimerCount()).toBe(0) + act(() => view.api.showToast('third')) + act(() => boundary.animationStarts[2].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => view.api.showToast('fourth')) + expect(vi.getTimerCount()).toBe(0) + act(() => boundary.animationStarts[3].complete({ finished: true })) + act(() => { + vi.advanceTimersByTime(1200) + }) + act(() => view.api.showToast('fifth')) + act(() => boundary.animationStarts[4].complete({ finished: true })) + expect(view.setToastMessage.mock.calls).toEqual([ + ['first'], + ['second'], + ['third'], + ['fourth'], + ['fifth'] + ]) + act(() => view.root.unmount()) +}) +it('keeps 64 admitted copies after 64 surface/hook unmounts without new timers', async () => { + vi.useFakeTimers() + const releases: (() => void)[] = [] + const copying: Promise[] = [] + const views: ReturnType[] = [] + for (let index = 0; index < 64; index++) { + boundary.writeText.mockReturnValueOnce( + new Promise((resolve) => { + releases.push(resolve) + }) + ) + const view = mounted() + views.push(view) + act(() => { + copying.push(view.api.handleSelectionCopy('handle', 'copy' + index)) + }) + act(() => view.root.unmount()) + } + expect(boundary.writeText.mock.calls).toEqual( + Array.from({ length: 64 }, (_, index) => ['copy' + index]) + ) + expect(vi.getTimerCount()).toBe(0) + await act(async () => { + for (const release of releases) { + release() + } + await Promise.all(copying) + }) + expect(boundary.success).toHaveBeenCalledTimes(64) + expect(boundary.error).not.toHaveBeenCalled() + for (const view of views) { + expect(view.cancelSelect).toHaveBeenCalledTimes(1) + expect(view.clearTerminalCache).toHaveBeenCalledTimes(1) + } + for (const animation of boundary.animationStarts) { + act(() => animation.complete({ finished: true })) + } + expect(vi.getTimerCount()).toBe(0) + expect(boundary.animationStarts).toHaveLength(0) +}) + +it('preserves synchronous native completion and natural hide cleanup', () => { + vi.useFakeTimers() + boundary.synchronous = true + const view = mounted() + act(() => view.api.showToast('sync')) + expect(vi.getTimerCount()).toBe(1) + act(() => { + vi.advanceTimersByTime(1200) + }) + expect(vi.getTimerCount()).toBe(0) + expect(view.setToastMessage.mock.calls).toEqual([['sync'], [null]]) + expect(boundary.animationStarts.map((start) => start.options)).toEqual([ + { toValue: 1, duration: 150, useNativeDriver: true }, + { toValue: 0, duration: 200, useNativeDriver: true } + ]) + act(() => view.root.unmount()) +}) +it('preserves reentrant newer-toast sequence and animation start order', () => { + vi.useFakeTimers() + const view = mounted() + view.setToastMessage.mockImplementationOnce(() => view.api.showToast('inner')) + act(() => view.api.showToast('outer')) + expect(view.setToastMessage.mock.calls).toEqual([['outer'], ['inner']]) + expect(boundary.animationStarts).toHaveLength(2) + act(() => boundary.animationStarts[0].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => boundary.animationStarts[1].complete({ finished: true })) + expect(vi.getTimerCount()).toBe(1) + act(() => { + vi.advanceTimersByTime(1200) + }) + act(() => boundary.animationStarts[2].complete({ finished: true })) + expect(view.setToastMessage.mock.calls).toEqual([['outer'], ['inner'], [null]]) + act(() => view.root.unmount()) +}) diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts index a71ad83829d..8fe58a8ccd6 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -34,6 +34,14 @@ export function useMobileNativeChatFileSearch(args: { new GenerationScopedRequestOwner() ).current + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + useEffect(() => { sequenceRef.current++ queryCacheRef.current.clear() @@ -90,6 +98,9 @@ export function useMobileNativeChatFileSearch(args: { setNativeChatFilePaths(paths) } const loadLegacyPaths = async (): Promise => { + if (!mountedRef.current) { + return + } // What retires the inventory: this host, this workspace, this logical authority. A // reconnect to the same host leaves the files on disk alone, so the physical session // epoch is deliberately not in it. Read once, so a cutover between the two calls below diff --git a/mobile/src/session/use-mobile-session-feedback-capabilities.ts b/mobile/src/session/use-mobile-session-feedback-capabilities.ts index ea8d6438bf4..ad0fb781363 100644 --- a/mobile/src/session/use-mobile-session-feedback-capabilities.ts +++ b/mobile/src/session/use-mobile-session-feedback-capabilities.ts @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from 'react' +import { useState, useRef, useCallback, useEffect } from 'react' import { Animated } from 'react-native' import { reconcileMobileSessionCreateWarningState } from './mobile-session-create-warning-state' import type { MobileSessionTerminalRuntimeModel } from './use-mobile-session-terminal-runtime' @@ -60,6 +60,14 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina } const createWarning = reconciledCreateWarningState.visible + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + const clearDelayedActionTimers = useCallback(() => { for (const timer of delayedActionTimersRef.current) { clearTimeout(timer) @@ -85,6 +93,9 @@ export function useMobileSessionFeedbackCapabilities(scope: MobileSessionTermina const showToast = useCallback( (message: string, durationMs = 1200) => { + if (!mountedRef.current) { + return + } const seq = toastSeqRef.current + 1 toastSeqRef.current = seq clearToastHideTimer() diff --git a/mobile/src/test-support/rpc-recording/derived-goldens.ts b/mobile/src/test-support/rpc-recording/derived-goldens.ts index 67ffa4a7e13..940b04e0645 100644 --- a/mobile/src/test-support/rpc-recording/derived-goldens.ts +++ b/mobile/src/test-support/rpc-recording/derived-goldens.ts @@ -53,6 +53,24 @@ function baseScenario(manifest: readonly RecordingScenario[], id: string): Recor return found } +function omitRetiredInventoryAdmission(scenario: RecordingScenario): RecordingScenario { + if (scenario.id !== 'inventory-lifecycle.unmount-before-1') { + return scenario + } + // A late search refusal cannot start an inventory after this owner's unmount. + return { + ...scenario, + steps: scenario.steps.filter( + (step) => + !( + ('bind' in step && + (step.bind === 'old-inventory' || step.bind === 'lifecycle-old-inventory')) || + ('complete' in step && step.complete === 'lifecycle-old-inventory') + ) + ) + } +} + /** The manifest scenario a pilot golden expands from, which its suite also mounts and mutates. */ export type PilotGolden = DerivedGolden & { scenario: RecordingScenario } @@ -144,7 +162,7 @@ export function familyGoldens(manifest: readonly RecordingScenario[]): DerivedGo actions .flatMap((action) => lifecycleSchedules(base, action)) .filter(({ scenario }) => !id.includes('hydration') || !scenario.id.endsWith('-1')) - ) + ).map(omitRetiredInventoryAdmission) }) } return goldens diff --git a/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts b/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts new file mode 100644 index 00000000000..9cf6c5947bc --- /dev/null +++ b/mobile/src/test-support/rpc-recording/inventory-lifecycle-derivation.test.ts @@ -0,0 +1,64 @@ +import { resolve } from 'node:path' +import { expect, it } from 'vitest' +import { familyGoldens } from './derived-goldens' +import { hoistPreludeCheckpoints } from './prelude-checkpoints' +import { readScenarios } from './scenario-input' +import { bindCompletions, lifecycleSchedules } from './schedule-driver' + +it('omits only the inventory admission following a refusal to a fully unmounted owner', () => { + const manifest = readScenarios( + resolve(import.meta.dirname, '../../../rpc-foundation/pilot-scenarios.json') + ) + const base = manifest.scenarios.find((scenario) => scenario.id === 'inventory-lifecycle') + if (!base) { + throw new Error('missing inventory base scenario') + } + const golden = familyGoldens(manifest.scenarios).find( + (item) => item.id === 'lifecycle-inventory-lifecycle' + ) + if (!golden) { + throw new Error('missing inventory lifecycle golden') + } + const original = hoistPreludeCheckpoints( + { ...base, steps: bindCompletions(base.steps) }, + (['reset', 'unmount', 'blur'] as const).flatMap((action) => lifecycleSchedules(base, action)) + ) + const derived = golden.scenarios() + expect(derived.map((scenario) => scenario.id)).toEqual(original.map((scenario) => scenario.id)) + expect(derived).toHaveLength(12) + for (const [index, scenario] of original.entries()) { + if (scenario.id !== 'inventory-lifecycle.unmount-before-1') { + expect(derived[index]).toEqual(scenario) + expect(JSON.stringify(derived[index])).toBe(JSON.stringify(scenario)) + continue + } + expect(scenario.steps).toHaveLength(13) + expect(scenario.steps.slice(7, 10)).toEqual([ + { bind: 'old-inventory', request: 'files.list#1', params: { worktree: 'id:A' } }, + { bind: 'lifecycle-old-inventory', request: 'old-inventory', params: { worktree: 'id:A' } }, + { + complete: 'lifecycle-old-inventory', + params: { worktree: 'id:A' }, + reply: { ok: true, result: { files: [{ relativePath: 'old.ts' }] } } + } + ]) + const expected = { + ...scenario, + steps: [...scenario.steps.slice(0, 7), ...scenario.steps.slice(10)] + } + expect(derived[index]).toEqual(expected) + expect(JSON.stringify(derived[index])).toBe(JSON.stringify(expected)) + expect(expected.steps.filter((step) => 'checkpoint' in step)).toEqual([ + { checkpoint: 'lifecycle-boundary' }, + { checkpoint: 'settled' }, + { checkpoint: 'remounted' } + ]) + expect(expected.steps.filter((step) => 'bind' in step)).toEqual([ + { + bind: 'lifecycle-files.searchPaths#1', + request: 'files.searchPaths#1', + params: { worktree: 'id:A', query: 'old', limit: 16 } + } + ]) + } +}) diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index c2f24f71823..05f66979e8b 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -130,6 +130,9 @@ export class MobileRelayE2eeLink { // `error` is often delivered just before `close`; wait for close so a // typed relay code is not replaced by a generic transport error. this.socket.onerror = () => { + if (this.closed) { + return + } this.transportErrorTimer ??= setTimeout(() => { this.transportErrorTimer = null this.fail(new RelayOuterError(1006)) diff --git a/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts b/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts new file mode 100644 index 00000000000..4727f34646e --- /dev/null +++ b/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason' + +vi.mock('./mobile-e2ee-v2-client-session', () => ({ + MobileE2EEV2ClientSession: { create: () => ({}) } +})) + +vi.mock('./mobile-e2ee-v2-physical-channel', () => ({ + MobileE2EEV2PhysicalChannel: class { + start = vi.fn() + handleMessage = vi.fn(async () => {}) + sendText = vi.fn(() => true) + sendBinary = vi.fn(() => true) + dispose = vi.fn() + } +})) + +import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link' + +class ErrorTimerSocket { + readonly OPEN = 1 + readyState = 1 + bufferedAmount = 0 + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + onclose: ((event: { code: number; reason: string }) => void) | null = null + send = vi.fn((_frame: string) => {}) + close = vi.fn(() => {}) +} + +function linkFixture(): { + link: MobileRelayE2eeLink + socket: ErrorTimerSocket + onError: ReturnType + onHostCloseReason: ReturnType +} { + const socket = new ErrorTimerSocket() + const onError = vi.fn() + const onHostCloseReason = vi.fn() + const link = new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + onHostCloseReason, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The observed socket implements every WebSocket member this link and mocked channel use. + createSocket: () => socket as unknown as WebSocket + }) + return { link, socket, onError, onHostCloseReason } +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('closed relay link transport-error timer ownership', () => { + it('starts no fallback for errors queued after explicit close', () => { + const { link, socket, onError } = linkFixture() + link.close() + socket.onerror?.() + socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(onError).not.toHaveBeenCalled() + expect(socket.close).toHaveBeenCalledOnce() + }) + + it('starts no fallback after failure and preserves the original thrown error', () => { + const { socket, onError } = linkFixture() + const failure = new Error('relay auth write failed') + socket.send.mockImplementation(() => { + throw failure + }) + socket.onopen?.() + socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(onError).toHaveBeenCalledExactlyOnceWith(failure) + expect(socket.close).toHaveBeenCalledOnce() + }) + + it.each(['close', 'fail', 'fallback'])( + 'starts no fallback for a reentrant socket.close error during %s', + async (mode) => { + const { link, socket, onError } = linkFixture() + socket.close.mockImplementation(() => { + socket.onerror?.() + }) + if (mode === 'close') { + link.close() + expect(onError).not.toHaveBeenCalled() + } else if (mode === 'fail') { + socket.onclose?.({ code: 4409, reason: '' }) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409' }) + ) + } else { + socket.onerror?.() + await vi.advanceTimersByTimeAsync(250) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_1006' }) + ) + } + expect(vi.getTimerCount()).toBe(0) + expect(socket.close).toHaveBeenCalledOnce() + } + ) + + it('starts no fallback after normal close and still reports its host reason', () => { + const { socket, onError, onHostCloseReason } = linkFixture() + socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT }) + socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409' }) + ) + expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) + }) + + it('retains one live fallback and the exact missing-close grace deadline', async () => { + const { socket, onError } = linkFixture() + socket.onerror?.() + socket.onerror?.() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(249) + expect(onError).not.toHaveBeenCalled() + expect(socket.close).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_1006' }) + ) + expect(socket.close).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('retains a typed close and host reason during the live error grace', async () => { + const { socket, onError, onHostCloseReason } = linkFixture() + socket.onerror?.() + await vi.advanceTimersByTimeAsync(249) + socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT }) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409' }) + ) + expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) + }) + + it('retains late host-reason reporting after a rejected handshake', async () => { + const { socket, onError, onHostCloseReason } = linkFixture() + socket.onmessage?.({ data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4409 }) }) + await vi.advanceTimersByTimeAsync(0) + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409' }) + ) + socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT }) + expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) + expect(onError).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps a replacement link independent and leaves no timer after its close', async () => { + const old = linkFixture() + old.link.close() + const replacement = linkFixture() + old.socket.onerror?.() + replacement.socket.onerror?.() + expect(vi.getTimerCount()).toBe(1) + replacement.socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT }) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(250) + expect(old.onError).not.toHaveBeenCalled() + expect(replacement.onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409' }) + ) + expect(replacement.onHostCloseReason).toHaveBeenCalledExactlyOnceWith( + RELAY_HOST_CLOSE_REASON.SIGNED_OUT + ) + }) +}) diff --git a/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts b/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts new file mode 100644 index 00000000000..13236d69cc7 --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionLogEntry } from './types' + +type ChannelRecord = { + options: { onAuthenticated(): void; onText(value: string): void; onError(error: Error): void } + start: ReturnType + sendText: ReturnType + dispose: ReturnType +} + +const observed = vi.hoisted(() => ({ channels: new Array() })) +vi.mock('./mobile-e2ee-v2-client-session', () => ({ + MobileE2EEV2ClientSession: { create: () => ({}) } +})) +vi.mock('./mobile-e2ee-v2-physical-channel', () => ({ + MobileE2EEV2PhysicalChannel: class { + start = vi.fn() + handleMessage = vi.fn(async () => {}) + sendText = vi.fn((_frame: string) => true) + dispose = vi.fn() + constructor(readonly options: ChannelRecord['options']) { + observed.channels.push(this) + } + } +})) + +import { connectMobileRelayForPairing, RelayOuterError } from './mobile-relay-physical-client' + +class PairingTimerSocket { + readonly OPEN = 1 + readyState = 1 + bufferedAmount = 0 + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + onclose: ((event: { code: number }) => void) | null = null + send = vi.fn((_frame: string) => {}) + close = vi.fn(() => {}) +} + +function fixture() { + const socket = new PairingTimerSocket() + const logs: ConnectionLogEntry[] = [] + const client = connectMobileRelayForPairing({ + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678', + inviteExpiresAt: Date.now() + 300_000, + e2eeFraming: 2 + }, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + onLog: (entry) => logs.push(entry), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The observed socket implements every WebSocket member this client and mocked channel use. + createSocket: () => socket as unknown as WebSocket + }) + const channel = observed.channels.at(-1) + if (!channel) { + throw new Error('missing observed pairing channel') + } + return { client, socket, logs, channel } +} + +async function authenticate(target: ReturnType): Promise { + target.socket.onmessage?.({ + data: JSON.stringify({ + type: 'relay-hello', + ok: true, + credentialKind: 'invite', + leaseExpiresAt: Date.now() + 60_000 + }) + }) + await vi.advanceTimersByTimeAsync(0) + expect(target.channel.start).toHaveBeenCalledOnce() + target.channel.options.onAuthenticated() +} + +function closedLogs(logs: ConnectionLogEntry[]): ConnectionLogEntry[] { + return logs.filter((entry) => entry.message === 'Relay: pairing socket closed') +} + +beforeEach(() => { + observed.channels.length = 0 + vi.useFakeTimers() +}) +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('closed relay pairing client error timer ownership', () => { + it('keeps authentication waiter rejection and intentional-close log exact without a late alarm', async () => { + const target = fixture() + const first = target.client.sendRequest('first').catch((error: unknown) => error) + const second = target.client.sendRequest('second').catch((error: unknown) => error) + target.client.close() + const failure = await first + expect(failure).toEqual(new Error('relay pairing client closed')) + expect(await second).toBe(failure) + target.socket.onerror?.() + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'info', detail: 'relay-c1.onorca.dev' }) + ]) + expect(target.channel.dispose).toHaveBeenCalledOnce() + expect(target.socket.close).toHaveBeenCalledOnce() + }) + + it('keeps the original error for every pending RPC and clears their timers before late errors', async () => { + const target = fixture() + await authenticate(target) + const first = target.client.sendRequest('first').catch((error: unknown) => error) + const second = target.client.sendRequest('second').catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBe(2) + const failure = new Error('channel failed') + target.channel.options.onError(failure) + expect(await first).toBe(failure) + expect(await second).toBe(failure) + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'warn', detail: 'Error: channel failed' }) + ]) + expect(target.channel.dispose).toHaveBeenCalledOnce() + expect(target.socket.close).toHaveBeenCalledOnce() + }) + + it.each(['intentional', 'channel', 'fallback'])( + 'starts no timer for reentrant socket.close errors during %s', + async (mode) => { + const target = fixture() + const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error) + target.socket.close.mockImplementation(() => target.socket.onerror?.()) + const failure = new Error('channel failed') + if (mode === 'intentional') { + target.client.close() + expect(await waiting).toEqual(new Error('relay pairing client closed')) + } else if (mode === 'channel') { + target.channel.options.onError(failure) + expect(await waiting).toBe(failure) + } else { + target.socket.onerror?.() + await vi.advanceTimersByTimeAsync(250) + expect(await waiting).toEqual(new RelayOuterError(1006)) + } + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toHaveLength(1) + expect(target.channel.dispose).toHaveBeenCalledOnce() + expect(target.socket.close).toHaveBeenCalledOnce() + } + ) + + it('keeps a normal typed close rejection and log without a late alarm', async () => { + const target = fixture() + const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error) + target.socket.onclose?.({ code: 4409 }) + expect(await waiting).toEqual(new RelayOuterError(4409)) + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'warn', detail: 'relay close code 4409' }) + ]) + }) + + it('keeps a rejected hello error and log without a late alarm', async () => { + const target = fixture() + const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error) + target.socket.onmessage?.({ + data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 }) + }) + expect(await waiting).toEqual(new RelayOuterError(4404)) + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'warn', detail: 'relay close code 4404' }) + ]) + expect(target.channel.start).not.toHaveBeenCalled() + }) + + it('retains one live alarm and exactly the 250 ms missing-close grace', async () => { + const target = fixture() + const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error) + target.socket.onerror?.() + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(249) + expect(closedLogs(target.logs)).toHaveLength(0) + expect(target.channel.dispose).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(await waiting).toEqual(new RelayOuterError(1006)) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'warn', detail: 'relay close code 1006' }) + ]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('retains a typed close during the live error grace', async () => { + const target = fixture() + const waiting = target.client.sendRequest('status.get').catch((error: unknown) => error) + target.socket.onerror?.() + await vi.advanceTimersByTimeAsync(249) + target.socket.onclose?.({ code: 4409 }) + expect(await waiting).toEqual(new RelayOuterError(4409)) + await vi.advanceTimersByTimeAsync(1) + expect(vi.getTimerCount()).toBe(0) + expect(closedLogs(target.logs)).toEqual([ + expect.objectContaining({ level: 'warn', detail: 'relay close code 4409' }) + ]) + }) + + it('preserves an already settled RPC through explicit close and late errors', async () => { + const target = fixture() + await authenticate(target) + const pending = target.client.sendRequest('status.get') + await vi.advanceTimersByTimeAsync(0) + target.channel.options.onText(JSON.stringify({ id: 'relay-pair-1', ok: true, result: 'done' })) + const response = await pending + expect(response).toEqual({ id: 'relay-pair-1', ok: true, result: 'done' }) + target.client.close() + target.socket.onerror?.() + expect(vi.getTimerCount()).toBe(0) + expect(await pending).toBe(response) + expect(closedLogs(target.logs)).toHaveLength(1) + expect(target.channel.dispose).toHaveBeenCalledOnce() + }) + + it('lets a replacement own its single alarm and leaves none after its close', async () => { + const old = fixture() + const oldWaiting = old.client.sendRequest('old').catch((error: unknown) => error) + old.client.close() + expect(await oldWaiting).toEqual(new Error('relay pairing client closed')) + const replacement = fixture() + const waiting = replacement.client.sendRequest('new').catch((error: unknown) => error) + old.socket.onerror?.() + replacement.socket.onerror?.() + expect(vi.getTimerCount()).toBe(1) + replacement.socket.onclose?.({ code: 4409 }) + expect(await waiting).toEqual(new RelayOuterError(4409)) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(250) + expect(closedLogs(old.logs)).toHaveLength(1) + expect(closedLogs(replacement.logs)).toHaveLength(1) + expect(old.channel.dispose).toHaveBeenCalledOnce() + expect(replacement.channel.dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index 3df30542ffa..0883f6bf14a 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -118,6 +118,9 @@ export function connectMobileRelayForPairing(args: { // WebSocket implementations commonly emit `error` immediately before // `close`; the bounded fallback represents an opaque 1006 close. socket.onerror = () => { + if (closed) { + return + } transportErrorTimer ??= setTimeout(() => { transportErrorTimer = null fail(new RelayOuterError(1006)) diff --git a/mobile/src/worktree/agent-row-lineage-parity.test.ts b/mobile/src/worktree/agent-row-lineage-parity.test.ts new file mode 100644 index 00000000000..10a73d47a4f --- /dev/null +++ b/mobile/src/worktree/agent-row-lineage-parity.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import { + buildAgentRowLineageTree, + flattenAgentRowLineage, + type AgentRowNode +} from './agent-row-lineage' + +function row(paneKey: string, parentPaneKey: string | null): RuntimeWorktreeAgentRow { + return { + paneKey, + parentPaneKey, + state: 'working', + agentType: 'claude', + prompt: '', + taskTitle: null, + displayName: null, + lastAssistantMessage: null, + toolName: null, + toolInput: null, + interrupted: false, + stateStartedAt: 0, + updatedAt: 0 + } +} + +// Frozen pre-optimization traversal: duplicate pane keys may be emitted on distinct branches. +function legacyFlatten(rows: readonly RuntimeWorktreeAgentRow[]): AgentRowNode[] { + const { rootRows, childrenByParentPaneKey } = buildAgentRowLineageTree(rows) + const out: AgentRowNode[] = [] + const seen = new Set() + const visit = (agent: RuntimeWorktreeAgentRow, depth: number, ancestors: ReadonlySet) => { + if (ancestors.has(agent.paneKey)) { + return + } + seen.add(agent.paneKey) + out.push({ row: agent, depth, children: [] }) + const nextAncestors = new Set(ancestors) + nextAncestors.add(agent.paneKey) + for (const child of childrenByParentPaneKey.get(agent.paneKey) ?? []) { + visit(child, depth + 1, nextAncestors) + } + } + for (const root of rootRows) { + visit(root, 0, new Set()) + } + for (const agent of rows) { + if (!seen.has(agent.paneKey)) { + seen.add(agent.paneKey) + out.push({ row: agent, depth: 0, children: [] }) + } + } + return out +} + +describe('agent lineage traversal parity', () => { + it('matches the previous traversal across cycles, dangling parents, duplicates, and input order', () => { + const variants = ['a', 'b', 'c'].flatMap((paneKey) => + [null, 'a', 'b', 'c', 'missing'].map((parentPaneKey) => row(paneKey, parentPaneKey)) + ) + expect(flattenAgentRowLineage([])).toEqual(legacyFlatten([])) + for (const first of variants) { + for (const second of variants) { + for (const third of variants) { + const rows = [first, second, third] + const expected = legacyFlatten(rows) + const actual = flattenAgentRowLineage(rows) + expect(actual).toEqual(expected) + actual.forEach((node, index) => expect(node.row).toBe(expected[index]?.row)) + } + } + } + }) + + it('releases the ancestor path between duplicate roots and sibling branches', () => { + const firstRoot = row('root', null) + const secondRoot = row('root', null) + const firstChild = row('child', 'root') + const secondChild = row('child', 'root') + const grandchild = row('grandchild', 'child') + const rows = [firstRoot, firstChild, secondChild, grandchild, secondRoot] + const actual = flattenAgentRowLineage(rows) + expect(actual).toEqual(legacyFlatten(rows)) + expect(actual.map((node) => [rows.indexOf(node.row), node.depth])).toEqual([ + [0, 0], + [1, 1], + [3, 2], + [2, 1], + [3, 2], + [4, 0], + [1, 1], + [3, 2], + [2, 1], + [3, 2] + ]) + }) + + it('copies no ancestor members while traversing a long lineage', () => { + const rows = Array.from({ length: 512 }, (_, index) => + row(`pane-${index}`, index === 0 ? null : `pane-${index - 1}`) + ) + const NativeSet = globalThis.Set + let copiedMembers = 0 + class ObservedSet extends NativeSet { + constructor(values?: Iterable | null) { + super() + if (values) { + for (const value of values) { + copiedMembers += 1 + this.add(value) + } + } + } + } + vi.stubGlobal('Set', ObservedSet) + let actual: AgentRowNode[] + try { + actual = flattenAgentRowLineage(rows) + } finally { + vi.unstubAllGlobals() + } + expect(copiedMembers).toBe(0) + expect(actual.map((node) => [node.row.paneKey, node.depth])).toEqual( + rows.map((agent, index) => [agent.paneKey, index]) + ) + }) +}) diff --git a/mobile/src/worktree/agent-row-lineage.ts b/mobile/src/worktree/agent-row-lineage.ts index 4d3fe7ffe25..2c48c5b09c9 100644 --- a/mobile/src/worktree/agent-row-lineage.ts +++ b/mobile/src/worktree/agent-row-lineage.ts @@ -59,21 +59,22 @@ export function flattenAgentRowLineage(rows: readonly RuntimeWorktreeAgentRow[]) const { rootRows, childrenByParentPaneKey } = buildAgentRowLineageTree(rows) const out: AgentRowNode[] = [] const seen = new Set() - const visit = (row: RuntimeWorktreeAgentRow, depth: number, ancestors: ReadonlySet) => { + const ancestors = new Set() + const visit = (row: RuntimeWorktreeAgentRow, depth: number) => { if (ancestors.has(row.paneKey)) { return } seen.add(row.paneKey) const node: AgentRowNode = { row, depth, children: [] } out.push(node) - const nextAncestors = new Set(ancestors) - nextAncestors.add(row.paneKey) + ancestors.add(row.paneKey) for (const child of childrenByParentPaneKey.get(row.paneKey) ?? []) { - visit(child, depth + 1, nextAncestors) + visit(child, depth + 1) } + ancestors.delete(row.paneKey) } for (const root of rootRows) { - visit(root, 0, new Set()) + visit(root, 0) } // Why: a cyclic component that coexists with a normal rooted tree has no entry // in rootRows and is unreachable from any root, so it would silently vanish. diff --git a/package.json b/package.json index 3e67dfb4813..3c1979d5ff0 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,6 @@ "@floating-ui/dom": "1.8.0", "@linear/sdk": "^97.0.0", "@parcel/watcher": "^2.5.6", - "@streamparser/json": "0.0.26", "@xterm/addon-serialize": "0.15.0-beta.300", "@xterm/headless": "6.1.0-beta.302", "agent-browser": "~0.27.0", @@ -198,6 +197,8 @@ "sherpa-onnx": "1.12.37", "smol-toml": "1.8.0", "ssh2": "^1.17.0", + "stream-chain": "4.2.6", + "stream-json": "3.7.0", "tldts": "7.4.16", "tweetnacl": "^1.0.3", "ws": "^8.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fb1428002d..fbf12ea1ae1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,9 +196,6 @@ importers: '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 - '@streamparser/json': - specifier: 0.0.26 - version: 0.0.26 '@xterm/addon-serialize': specifier: 0.15.0-beta.300 version: 0.15.0-beta.300(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) @@ -244,6 +241,12 @@ importers: ssh2: specifier: ^1.17.0 version: 1.17.0 + stream-chain: + specifier: 4.2.6 + version: 4.2.6 + stream-json: + specifier: 3.7.0 + version: 3.7.0 tldts: specifier: 7.4.16 version: 7.4.16 @@ -3185,9 +3188,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@streamparser/json@0.0.26': - resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==} - '@swc/core-darwin-arm64@1.15.46': resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} engines: {node: '>=10'} @@ -7354,6 +7354,12 @@ packages: resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} engines: {node: '>=18'} + stream-chain@4.2.6: + resolution: {integrity: sha512-1zeJ8CrtJfmiba26ui8jXkq/xLRFvhzkdH02D5QLO9Cnovgeb28IJxg88DdraWnjnkbOol/++uIAybJbJhk7ig==} + + stream-json@3.7.0: + resolution: {integrity: sha512-rCSBdcBP/bPk6T8QFcxAj1MSzAuc5i49cYW6IE7sYObNEPccBJIUiL6fU9c9BWt40aJK0siVynLX7lWaW8alXw==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -10208,8 +10214,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@streamparser/json@0.0.26': {} - '@swc/core-darwin-arm64@1.15.46': optional: true @@ -14965,6 +14969,12 @@ snapshots: stdin-discarder@0.3.2: {} + stream-chain@4.2.6: {} + + stream-json@3.7.0: + dependencies: + stream-chain: 4.2.6 + strict-event-emitter@0.5.1: optional: true diff --git a/src/cli/handlers/search.test.ts b/src/cli/handlers/search.test.ts index c65a96ebe5c..2db6b9785fc 100644 --- a/src/cli/handlers/search.test.ts +++ b/src/cli/handlers/search.test.ts @@ -63,7 +63,17 @@ async function runSearch( ): Promise<{ call: ReturnType; output: string }> { const call = options.error ? vi.fn().mockRejectedValue(options.error) - : vi.fn().mockResolvedValue(envelope(options.result ?? resultsResponse)) + : vi + .fn() + .mockImplementation((method: string) => + Promise.resolve( + envelope( + method === 'aiVault.searchStatus' + ? statusResponse + : (options.result ?? resultsResponse) + ) + ) + ) const lines: string[] = [] vi.spyOn(console, 'log').mockImplementation((value: unknown) => { lines.push(String(value)) @@ -161,9 +171,13 @@ describe('orca search over the runtime RPC', () => { ] ] - it.each(flagCases)('sends %s', async (_name, flags, params) => { + it.each(flagCases)('sends %s', async (name, flags, params) => { const { call } = await runSearch(flags) + expect(call).toHaveBeenCalledTimes(name === 'filters' ? 2 : 1) + if (name === 'filters') { + expect(call).toHaveBeenNthCalledWith(1, 'aiVault.searchStatus', {}) + } expect(call).toHaveBeenCalledWith('aiVault.searchSessions', { ...params, supportedAgents: [...AI_VAULT_AGENTS], diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 2d161a01289..c8de61a9fda 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 2187aaca0f0..18850b4a0ef 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -45,6 +45,40 @@ __orca_osc133_preexec() { # which prints a warning above every command under warn_create_global. builtin typeset -g __orca_in_command=1 } +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -54,12 +88,27 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi if __orca_has_feature markers; then precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) else precmd_functions=(${precmd_functions:#__orca_deferred_init}) fi + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -202,13 +251,25 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index fd151e03b12..05f3da594e0 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 7172b28d2dc..b3c8a92869c 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -45,6 +45,40 @@ __orca_osc133_preexec() { # which prints a warning above every command under warn_create_global. builtin typeset -g __orca_in_command=1 } +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -54,8 +88,17 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi if __orca_has_feature markers; then precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) else precmd_functions=(${precmd_functions:#__orca_deferred_init}) @@ -67,6 +110,12 @@ __orca_deferred_init() { printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2 fi fi + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -219,13 +268,25 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index 3042bb11df4..e9cc47d8361 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -26,6 +26,12 @@ fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" [[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep # their normal argv shape. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 102534564b7..21dce8480e6 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -31,6 +31,40 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" builtin unset ORCA_HISTFILE __orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } __orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -40,7 +74,21 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi precmd_functions=(${precmd_functions:#__orca_deferred_init}) + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" @@ -170,13 +218,25 @@ __orca_deferred_init() { zle -N zle-line-init __orca_prompt_mark fi builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts index 4cd1905d8aa..30fe4a658a9 100644 --- a/src/main/agent-hooks/managed-hook-script-refresh.test.ts +++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts @@ -147,8 +147,10 @@ describe('managed hook script refresh', () => { homedirMock.mockReturnValue(home) const previousGrokHome = process.env.GROK_HOME const previousKimiHome = process.env.KIMI_CODE_HOME + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME delete process.env.GROK_HOME delete process.env.KIMI_CODE_HOME + delete process.env.XDG_CONFIG_HOME try { await withPlatform('win32', () => { for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) { @@ -187,6 +189,11 @@ describe('managed hook script refresh', () => { } else { process.env.KIMI_CODE_HOME = previousKimiHome } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome + } rmSync(home, { recursive: true, force: true }) } }) diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts new file mode 100644 index 00000000000..19a6efd5a89 --- /dev/null +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -0,0 +1,146 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' +import { PANE } from './server.test-fixtures' + +describe('startup prompt control with status hooks disabled', () => { + it.each([false, true])( + 'persists a pending terminal status at shutdown after starting with hooks %s', + async (statusHooksEnabled) => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled }) + server.setStatusHooksEnabled(false) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'folder-1', + payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' } + }) + expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done') + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + server.stop() + expect(existsSync(statusPath)).toBe(true) + expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({ + paneKey: PANE, + worktreeId: 'folder-1', + payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' } + }) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + } + ) + + it('enables and disables status without restarting the control listener', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-')) + class IsolatedHookServer extends AgentHookServer { + constructor() { + super() + this._setOpenCodeBinderDepsForTests({ + dbPath: () => join(dir, 'no-user-db'), + listSessions: async () => [], + listPanes: () => [], + sweep: async () => [] + }) + } + } + const server = new IsolatedHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'x-orca-agent-hook-token': coords.token }, + body: '{}' + }) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start({ statusHooksEnabled: true }) + expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port) + expect((await post('/hook/opencode')).status).toBe(204) + server.setStatusHooksEnabled(false) + expect(server.buildPtyEnv()).toEqual({}) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start() + expect((await post('/hook/opencode')).status).toBe(404) + server.setStartupPromptClaimListener( + () => 'pending', + () => {} + ) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({ + allowed: false, + pending: true + }) + server.setStatusHooksEnabled(true) + expect((await post('/hook/opencode')).status).toBe(204) + expect(server.endpointFilePath).toBe(endpoint) + expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + expect(server.buildPtyEnv()).toEqual({}) + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + writeFileSync(statusPath, 'existing status must survive control-only shutdown') + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string, body: string, token = coords.token) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token }, + body + }) + expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: false + }) + const clear = vi.fn() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, clear) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: true + }) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({ + allowed: false + }) + expect(claim).toHaveBeenCalledTimes(1) + expect((await post('/hook/opencode', '{}')).status).toBe(404) + expect((await post('/statusline/claude', '{}')).status).toBe(404) + server.stop() + expect(clear).toHaveBeenCalledTimes(1) + expect(readFileSync(statusPath, 'utf8')).toBe( + 'existing status must survive control-only shutdown' + ) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server-transport-interference.test.ts b/src/main/agent-hooks/server-transport-interference.test.ts index 37c80087bdb..e8d5a8e357b 100644 --- a/src/main/agent-hooks/server-transport-interference.test.ts +++ b/src/main/agent-hooks/server-transport-interference.test.ts @@ -2,9 +2,11 @@ // short of its own Content-Length. The listener fails open on every request error, so the only // way this stays diagnosable is if the truncation is classified before it is swallowed. import { connect } from 'node:net' +import { ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference' import { AgentHookServer } from './server' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' async function postTruncatedHook( port: number, @@ -40,11 +42,15 @@ async function postTruncatedHook( } /** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */ -async function postStalledHook(port: number, token: string): Promise { +async function postStalledHook( + port: number, + token: string, + pathname = '/hook/claude' +): Promise { const socket = connect({ port, host: '127.0.0.1' }) await new Promise((resolve) => socket.on('connect', () => resolve())) socket.write( - `POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` + `POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` ) await new Promise((resolve) => { socket.on('close', () => resolve()) @@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => { expect(reports).toHaveLength(1) }, 20_000) + it('classifies an interrupted startup claim as retryable without consuming it', async () => { + const { server, port, token, reports } = await startServer() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, () => {}) + const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead') + try { + await postTruncatedHook(port, token, { + pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + sentBytes: '{"nonce":', + announcedLength: 1000 + }) + // The reset peer cannot receive this response; observe the real handler's classification. + expect(writeHead.mock.calls).toEqual([[503]]) + expect(claim).not.toHaveBeenCalled() + expect(reports).toEqual([]) + } finally { + writeHead.mockRestore() + } + }) + + it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => { + const { server, port, token, reports } = await startServer() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, () => {}) + const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead') + try { + await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH) + expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]]) + expect(claim).not.toHaveBeenCalled() + expect(reports).toEqual([]) + } finally { + writeHead.mockRestore() + } + }, 30_000) + it('never reports for POSTs that deliver their whole body', async () => { const { port, token, reports } = await startServer() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 80c81f09142..825b51b8a63 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' -import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' -import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' -export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { +export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { /** Start the loopback listener after hydration and spool replay have settled. */ async start(options?: { env?: string userDataPath?: string endpointNamespace?: string + statusHooksEnabled?: boolean }): Promise { if (this.server) { + if (options?.statusHooksEnabled !== undefined) { + this.setStatusHooksEnabled(options.statusHooksEnabled) + } return } + this.statusHooksEnabled = options?.statusHooksEnabled !== false if (options?.env) { this.env = options.env @@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (!this.ownerStateInitialized) { - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - const replayedPaneKeys = new Set() - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => { - this.ingestSpoolRecord(record) - replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) - } - }) - // Why: the owner may have died while Orca was down; check each replayed pane once. - for (const paneKey of replayedPaneKeys) { - void this.checkAgentPresence(paneKey) - } - } - this.ownerStateInitialized = true + if (this.statusHooksEnabled) { + this.initializeStatusHookOwner() } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname try { const body = await readRequestBody(req) + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + const claim = this.onStartupPromptClaim?.(body) + res.end( + JSON.stringify({ + allowed: claim === true, + ...(claim === 'pending' ? { pending: true } : {}) + }) + ) + return + } + if (!this.statusHooksEnabled) { + res.writeHead(404) + res.end() + return + } if (pathname === CLAUDE_STATUSLINE_PATHNAME) { const statusLineEvent = parseClaudeStatusLineBody(body) if (statusLineEvent) { @@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv res.writeHead(204) res.end() } catch (error) { + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { + res.writeHead(503) + res.end() + return + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"allowed":false}') + return + } // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut // by something on the loopback path, not by a bad payload. Fail open as before, but count it — // this is the one failure mode that silently stops status for every runtime at once. @@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.rollbackTransportStart() throw error } - this.startOpenCodeBinderLoop() + if (this.statusHooksEnabled) { + this.startOpenCodeBinderLoop() + } } private rollbackTransportStart(): void { @@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv } stop(): void { - // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() + // Terminal status may still have a pending write while hook ingress is disabled. + if (this.statusHooksEnabled || this.statusPersistTimer) { + this.flushStatusPersistSync() + } this.stopOpenCodeBinderLoop() this.stopTmuxStatus() this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null this.onClaudeStatusLine = null + this.clearStartupPromptClaims?.() + this.clearStartupPromptClaims = null + this.onStartupPromptClaim = null this.onPaneStatusCleared = null this.onTransportInterference = null this.transportInterference.reset() diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 1de09c3ebdf..14b78262734 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + setStartupPromptClaimListener( + listener: (body: unknown) => boolean | 'pending', + clear: () => void + ): void { + this.onStartupPromptClaim = listener + this.clearStartupPromptClaims = clear + } + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { this.onAgentStatus?.(enriched) for (const listener of this.enrichedStatusListeners) { diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts index 7bf7eaaee30..2f94bdd4b94 100644 --- a/src/main/agent-hooks/server/server-runtime-env.ts +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote' export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { + if (!this.statusHooksEnabled || this.port <= 0 || !this.token) { return {} } const env: Record = { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index f429c0e6350..172eaeaf1ef 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -89,6 +89,9 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null + protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null + protected clearStartupPromptClaims: (() => void) | null = null + protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() diff --git a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts new file mode 100644 index 00000000000..15a43182ab2 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts @@ -0,0 +1,52 @@ +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv { + setStatusHooksEnabled(enabled: boolean): void { + if (enabled === this.statusHooksEnabled) { + return + } + if (!enabled) { + this.flushStatusPersistSync() + this.stopOpenCodeBinderLoop() + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllTranscriptPolls() + } + this.statusHooksEnabled = enabled + if (enabled && this.server) { + this.initializeStatusHookOwner() + this.startOpenCodeBinderLoop() + } + } + + protected initializeStatusHookOwner(): void { + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + const replayedPaneKeys = new Set() + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => { + this.ingestSpoolRecord(record) + replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) + } + }) + // Why: the owner may have died while Orca was down; check each replayed pane once. + for (const paneKey of replayedPaneKeys) { + void this.checkAgentPresence(paneKey) + } + } + this.ownerStateInitialized = true + } + } +} diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index e956494fc76..e0052aa8b45 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -6,6 +6,7 @@ import { readFileSync, realpathSync, rmSync, + statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -122,6 +123,44 @@ describe('markCopilotFolderTrusted', () => { } }) + it('keeps a token-bearing config.json owner-only', () => { + if (process.platform === 'win32') { + return + } + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + // Why: a restrictive runner umask would mask a dropped mode. + const originalUmask = process.umask(0o022) + try { + mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true }) + writeFileSync(configPath, JSON.stringify({ copilotTokens: { a: 'secret' } }), { + mode: 0o600 + }) + markCopilotFolderTrusted(workspace, testState.fakeHomeDir) + expect(statSync(configPath).mode & 0o777).toBe(0o600) + expect(JSON.parse(readFileSync(configPath, 'utf-8')).copilotTokens).toEqual({ a: 'secret' }) + } finally { + process.umask(originalUmask) + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('creates a missing config.json owner-only', () => { + if (process.platform === 'win32') { + return + } + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const originalUmask = process.umask(0o022) + try { + markCopilotFolderTrusted(workspace, testState.fakeHomeDir) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + expect(statSync(configPath).mode & 0o777).toBe(0o600) + } finally { + process.umask(originalUmask) + rmSync(workspace, { recursive: true, force: true }) + } + }) + it('preserves existing config keys and dedups already-trusted folders', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) const realpath = realpathSync(workspace) diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 2d52ee005e4..eac0591728b 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -97,7 +97,8 @@ export function markCopilotFolderTrusted(workspacePath: string, home: string): v if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }) } - writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`) + // Why: config.json can hold copilotTokens, so it must stay owner-only (also on shared SSH hosts). + writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) } /** diff --git a/src/main/ai-vault-search/session-search-agent-compatibility.test.ts b/src/main/ai-vault-search/session-search-agent-compatibility.test.ts index a8c1ba96e75..d04cf73f667 100644 --- a/src/main/ai-vault-search/session-search-agent-compatibility.test.ts +++ b/src/main/ai-vault-search/session-search-agent-compatibility.test.ts @@ -53,9 +53,12 @@ test.each(['runtime', 'relay'] as const)( }, transport ) - ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) - expect(service.search).not.toHaveBeenCalled() - expect(service.reconcile).not.toHaveBeenCalled() + ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } }) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { query: 'proof', limit: 20, freshness: 'wait-until-current' }, + { kind: 'resolved', paths: [''] } + ) + expect(service.reconcile).toHaveBeenCalledTimes(1) } ) @@ -84,7 +87,7 @@ test.each(['qoder', 'jcode'] as const)( ) test.each(['runtime', 'relay'] as const)( - 'does not query or reconcile an unsupported-only filter on %s', + 'keeps an unsupported-only filter narrow through normal service checks on %s', async (transport) => { const request = { query: 'proof', @@ -105,14 +108,25 @@ test.each(['runtime', 'relay'] as const)( { ...request, filters: { agents: [...request.filters.agents] } }, transport ) - ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) - expect(service.search).not.toHaveBeenCalled() + ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } }) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { + query: 'proof', + limit: 20, + filters: { agents: ['jcode'] }, + freshness: 'wait-until-current' + }, + { kind: 'resolved', paths: [''] } + ) expect(service.status).not.toHaveBeenCalled() - expect(service.reconcile).not.toHaveBeenCalled() + expect(service.reconcile).toHaveBeenCalledTimes(1) for (const reason of ['disabled', 'not-ready'] as const) { service.search.mockResolvedValue({ kind: 'unavailable', reason }) expect( - await searchSessionService({ query: 'proof', filters: { agents: ['codex'] } }, transport) + await searchSessionService( + { query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } }, + transport + ) ).toEqual({ kind: 'unavailable', reason }) } } diff --git a/src/main/ai-vault-search/session-search-service-registry.test.ts b/src/main/ai-vault-search/session-search-service-registry.test.ts index d8fb2c081bc..8e172e5f2c1 100644 --- a/src/main/ai-vault-search/session-search-service-registry.test.ts +++ b/src/main/ai-vault-search/session-search-service-registry.test.ts @@ -86,8 +86,8 @@ describe('session search service registry', () => { { query: 'proof', supportedAgents: ['claude'], filters: { agents: [agent] } }, transport ) - ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) - expect(service.search).toHaveBeenCalledTimes(1) + ).toMatchObject({ kind: 'results', hits: [], page: { cursor: null, hasMore: false } }) + expect(service.search).toHaveBeenCalledTimes(2) expect(service.reconcile).not.toHaveBeenCalled() const client = createSessionSearchClient( (method, request) => @@ -114,6 +114,24 @@ describe('session search service registry', () => { ) expect((await sessionSearchServiceStatus({}, 'ipc')).supportedAgents).toEqual(AI_VAULT_AGENTS) }) + it.each(['disabled', 'not-ready'] as const)( + 'preserves %s precedence for empty negotiated host intersections', + async (reason) => { + const service = fakeSearchService() + service.search.mockResolvedValue({ kind: 'unavailable', reason }) + setSessionSearchService(service) + expect( + await searchSessionService( + { query: 'proof', supportedAgents: [], filters: { agents: ['jcode'] } }, + 'runtime' + ) + ).toEqual({ kind: 'unavailable', reason }) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { query: 'proof', limit: 20, filters: { agents: ['jcode'] } }, + { kind: 'resolved', paths: [''] } + ) + } + ) it('waits for reconcile before search, and clears its timeout', async () => { vi.useFakeTimers() const service = fakeSearchService() diff --git a/src/main/ai-vault-search/session-search-service-registry.ts b/src/main/ai-vault-search/session-search-service-registry.ts index 2f339185e64..02c448e11da 100644 --- a/src/main/ai-vault-search/session-search-service-registry.ts +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -52,11 +52,8 @@ export async function searchSessionService( supportsJcodeHistory } ) - if (compatibleAgents.length === 0) { - return { kind: 'unavailable', reason: 'unsupported-agent' } - } const compatibleRequest = - compatibleAgents.length === agents.length + compatibleAgents.length === 0 || compatibleAgents.length === agents.length ? request : { ...request, @@ -68,12 +65,16 @@ export async function searchSessionService( const hostScope = within ? resolveSessionSearchScope(within, sessionSearchScopeCatalog()) : undefined + const retrievalScope = + compatibleAgents.length === 0 && hostScope?.kind !== 'unknown' + ? { kind: 'resolved' as const, paths: [''] } + : hostScope const freshness = request.freshness === 'wait-until-current' ? await reconcileWithin(current, freshnessTimeoutMs) : false const result = AiVaultSearchResponseSchema.parse( - await current.search(compatibleRequest, hostScope) + await current.search(compatibleRequest, retrievalScope) ) if (result.kind !== 'results') { return result @@ -81,6 +82,7 @@ export async function searchSessionService( const { debug, ...fields } = result return { ...fields, + ...(compatibleAgents.length === 0 ? { page: { cursor: null, hasMore: false } } : {}), hits: result.hits .filter((hit) => compatibleAgents.includes(hit.agent)) .map((hit) => redactForTransport(hit, transport)), diff --git a/src/main/ai-vault/session-document-stream-contract.test.ts b/src/main/ai-vault/session-document-stream-contract.test.ts new file mode 100644 index 00000000000..3ab4053b303 --- /dev/null +++ b/src/main/ai-vault/session-document-stream-contract.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { readStreamedSessionDocument } from './session-document-stream' + +async function* bytes(content: string, chunkSize = 7): AsyncGenerator { + const buffer = Buffer.from(content) + for (let offset = 0; offset < buffer.length; offset += chunkSize) { + yield buffer.subarray(offset, offset + chunkSize) + } +} + +function read( + content: string, + overrides: Partial>[0]> = {} +) { + return readStreamedSessionDocument({ + bytes: bytes(content), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: (): unknown[] => [], + consume: (state, value) => { + state.push(value) + }, + ...overrides + }) +} + +describe('streamed session document contracts', () => { + it.each(['[]', 'null', 'false', '0', '"not an array"', '{}'])( + 'a later messages value %s clears an earlier fold', + async (last) => { + expect(await read(`{"messages":[1,2],"messages":${last}}`)).toEqual({ record: {}, state: [] }) + } + ) + + it('replaces a failed earlier array and continues with its successor', async () => { + const consume = (state: unknown[], value: unknown): void => { + if (value === 'bad') { + throw new Error('discarded failure') + } + state.push(value) + } + expect(await read('{"messages":["bad",1],"messages":[2,3]}', { consume })).toEqual({ + record: {}, + state: [2, 3] + }) + expect(await read('{"messages":["bad"],"messages":[]}', { consume })).toEqual({ + record: {}, + state: [] + }) + }) + + it('keeps the first consumer failure unless a later array replaces it', async () => { + const failure = new Error('consumer failed') + let calls = 0 + await expect( + read('{"messages":[1,2]}', { + consume: () => { + calls++ + throw failure + } + }) + ).rejects.toBe(failure) + expect(calls).toBe(1) + }) + + it('gives malformed trailing JSON precedence over a consumer failure', async () => { + await expect( + read('{"messages":[1]} trailing', { + consume: () => { + throw new Error('consumer') + } + }) + ).rejects.toBeInstanceOf(SyntaxError) + }) + + it('keeps projected object resets and full-field overlap precedence', async () => { + expect(await read('{"agent":{"model":"old"},"agent":null}')).toEqual({ + record: { agent: {} }, + state: [] + }) + expect(await read('{"agent":{"model":"m","extra":[1,2]}}', { fields: ['agent'] })).toEqual({ + record: { agent: { model: 'm', extra: [1, 2] } }, + state: [] + }) + expect(await read('{"messages":[1,2]}', { objectFields: { messages: ['model'] } })).toEqual({ + record: { messages: {} }, + state: [1, 2] + }) + expect( + await read('{"messages":{"model":"m","ignored":1}}', { + objectFields: { messages: ['model'] } + }) + ).toEqual({ record: { messages: { model: 'm' } }, state: [] }) + }) + + it('preserves selected prototype keys as own properties', async () => { + const content = + '{"id":{"__proto__":{"polluted":true}},"agent":{"model":{"constructor":"value","__proto__":{"x":1}}}}' + const parsed = await read(content) + expect(parsed?.record).toEqual(JSON.parse(content)) + expect(Object.getPrototypeOf(parsed?.record)).toBeNull() + expect(Object.prototype).not.toHaveProperty('polluted') + }) + + it.each(['', ' ', '{', '{"messages":[1,]}', '{"messages":[]}{"messages":[]}'])( + 'rejects malformed input %j', + async (content) => { + await expect(read(content)).rejects.toBeInstanceOf(SyntaxError) + } + ) + + it.each(['null', '[]', '123', '"text"'])( + 'does not publish a nonobject document %s', + async (content) => { + expect(await read(content)).toBeNull() + } + ) + + it('validates discarded subtrees and closes their source on malformed input', async () => { + let closed = false + async function* malformed() { + try { + yield Buffer.from('{"ignored":[{"deep":1},]}') + throw new Error('must not request another chunk') + } finally { + closed = true + } + } + await expect(read('', { bytes: malformed() })).rejects.toBeInstanceOf(SyntaxError) + expect(closed).toBe(true) + }) + + it('preserves source failure and closes the source even after a consumer failure', async () => { + const failure = new Error('disk failure') + let closed = false + async function* failing() { + try { + yield Buffer.from('{"messages":[1]') + throw failure + } finally { + closed = true + } + } + await expect( + read('', { + bytes: failing(), + consume: () => { + throw new Error('consumer') + } + }) + ).rejects.toBe(failure) + expect(closed).toBe(true) + }) + + it('preserves escaped astral text across large and byte-sized chunks', async () => { + const value = `${'x'.repeat(65530)}日本語😀\\literal` + const content = JSON.stringify({ messages: [value, '\ud800', '\udc00'] }) + for (const size of [1, 65536, content.length * 4]) { + expect(await read(content, { bytes: bytes(content, size) })).toEqual({ + record: {}, + state: [value, '\ud800', '\udc00'] + }) + } + }) +}) diff --git a/src/main/ai-vault/session-document-stream.ts b/src/main/ai-vault/session-document-stream.ts index 4d12ab0fcc2..4fcf24f7a55 100644 --- a/src/main/ai-vault/session-document-stream.ts +++ b/src/main/ai-vault/session-document-stream.ts @@ -1,6 +1,7 @@ import { StringDecoder } from 'node:string_decoder' -import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json' +import { FlexAssembler, arrayRule, objectRule } from 'stream-json/core/utils/flex-assembler.js' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { createJsonTokenReader } from '../../shared/json-token-reader' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' /** Fold one root array while retaining only the root fields the agent parser uses. */ @@ -13,92 +14,108 @@ export async function readStreamedSessionDocument(args: { consume: (state: T, value: unknown) => void signal?: AbortSignal }): Promise<{ record: Record; state: T } | null> { - const parser = new JSONParser({ - paths: [ - ...args.fields.map((field) => `$.${field}`), - ...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) => - fields.map((field) => `$.${root}.${field}`) - ), - ...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : []) - ], - keepStack: false, - stringBufferSize: 64 * 1024 - }) const record: Record = Object.create(null) const fields = new Set(args.fields) - let depth = 0 - let expectingRootKey = false - parser.onToken = ({ token, value }) => { - if (depth === 1 && expectingRootKey && token === TokenType.STRING) { - if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) { - record[value] = Object.create(null) - } - expectingRootKey = false - } - if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) { - if (depth === 0 && token === TokenType.LEFT_BRACE) { - expectingRootKey = true - } - depth++ - } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) { - depth-- - } else if (token === TokenType.COMMA && depth === 1) { - expectingRootKey = true - } - } + const objectFields = new Map( + Object.entries(args.objectFields ?? {}).map(([root, keys]) => [root, new Set(keys)]) + ) + const foldedArray = Symbol('folded session array') let state = args.create() - let currentArray: unknown = null let consumeFailure: { error: unknown } | undefined + let inFoldedArray = false + const reset = (): void => { + state = args.create() + consumeFailure = undefined + } + const retain = (path: (string | number)[]): boolean => { + const [root, child] = path + return ( + typeof root === 'string' && + ((root !== args.arrayKey && fields.has(root)) || + (inFoldedArray && root === args.arrayKey && path.length >= 2) || + (typeof child === 'string' && objectFields.get(root)?.has(child) === true)) + ) + } + // Every discarded container needs a rule; dropping only its parent still builds large children. + const discard = { + filter: (path: (string | number)[]) => !retain(path), + create: () => null, + add: () => {} + } + const assembler = new FlexAssembler({ + maxDepth: Infinity, + objectRules: [ + objectRule>({ + filter: (path) => path.length === 0, + create: () => record, + add: (target, key, value) => { + if (key === args.arrayKey) { + if (value !== foldedArray) { + reset() + } + } else if (fields.has(key)) { + target[key] = value + } + } + }), + objectRule>({ + filter: (path) => + path.length === 1 && + typeof path[0] === 'string' && + objectFields.has(path[0]) && + (!fields.has(path[0]) || path[0] === args.arrayKey), + create: (path) => { + const projected: Record = Object.create(null) + record[String(path[0])] = projected + return projected + }, + add: (target, key, value) => { + const root = assembler.path[0] + if (typeof root === 'string' && objectFields.get(root)?.has(key)) { + target[key] = value + } + } + }), + discard + ], + arrayRules: [ + arrayRule({ + filter: (path) => Boolean(args.arrayKey) && path.length === 1 && path[0] === args.arrayKey, + create: () => { + reset() + inFoldedArray = true + return null + }, + add: (_target, value) => { + if (!consumeFailure) { + try { + args.consume(state, value) + } catch (error) { + consumeFailure = { error } + } + } + }, + finalize: () => { + inFoldedArray = false + return foldedArray + } + }), + discard + ] + }) + const parser = createJsonTokenReader((token) => { + if ( + token.name === 'keyValue' && + assembler.depth === 1 && + !assembler.isArray && + objectFields.has(token.value) + ) { + record[token.value] = Object.create(null) + } + assembler.consume(token) + }) const decoder = new StringDecoder('utf8') let objectRoot: boolean | undefined - parser.onValue = ({ key, value, parent, stack }) => { - if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) { - if (parent !== currentArray) { - state = args.create() - consumeFailure = undefined - currentArray = parent - } - if (!consumeFailure) { - try { - args.consume(state, value) - } catch (error) { - consumeFailure = { error } - } - } - // The parser's array cursor is independent of retained array slots. - parent.pop() - } else if ( - stack.length === 2 && - typeof stack[1].key === 'string' && - typeof key === 'string' && - parent && - !Array.isArray(parent) - ) { - const root = stack[1].key - const projected = record[root] - if ( - Object.hasOwn(args.objectFields ?? {}, root) && - args.objectFields?.[root]?.includes(key) && - projected && - typeof projected === 'object' - ) { - Reflect.set(projected, key, value) - } - } else if (stack.length === 1 && typeof key === 'string') { - if (key === args.arrayKey) { - if (value !== currentArray || !Array.isArray(value)) { - state = args.create() - consumeFailure = undefined - } - currentArray = null - } else if (fields.has(key)) { - record[key] = value - } - if (parent && typeof parent === 'object') { - Reflect.deleteProperty(parent, key) - } - } - } for await (const chunk of args.bytes) { throwIfAiVaultScanCancelled(args.signal) if (objectRoot === undefined) { @@ -107,37 +124,17 @@ export async function readStreamedSessionDocument(args: { objectRoot = first === 123 } } - parseJson(() => parser.write(decoder.write(chunk))) + parser.write(decoder.write(chunk)) await yieldToEventLoop() } const tail = decoder.end() if (tail) { - parseJson(() => parser.write(tail)) - } - if (objectRoot === undefined) { - throw new SyntaxError('Unexpected end of JSON input') - } - if (!parser.isEnded) { - parseJson(() => parser.end(), true) + parser.write(tail) } + parser.end() throwIfAiVaultScanCancelled(args.signal) if (consumeFailure) { throw consumeFailure.error } return objectRoot ? { record, state } : null } - -function parseJson(run: () => void, ending = false): void { - try { - run() - } catch (error) { - if ( - error instanceof TokenizerError || - error instanceof TokenParserError || - (ending && error instanceof Error) - ) { - throw new SyntaxError(error.message) - } - throw error - } -} diff --git a/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts index 9e8511967f2..9b41f700b24 100644 --- a/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts @@ -73,8 +73,13 @@ describe('OpenCode reads through the production shared worker', () => { throw new Error('Expected the bounded native page') } expect(page.items.map((item) => item.rowid)).toEqual([2, 3]) + expect(page.items[0]?.message.role).toBe('system') expect(page.items[0]?.message.blocks).toEqual([ - { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] } + { + type: 'text', + text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'], + presentation: 'history-item-too-large' + } ]) const repeat = await client.readNativeChat(args) expect(repeat).toEqual(page) diff --git a/src/main/antigravity/native-account-store.test.ts b/src/main/antigravity/native-account-store.test.ts index f6d3398c679..9fbd8ab183f 100644 --- a/src/main/antigravity/native-account-store.test.ts +++ b/src/main/antigravity/native-account-store.test.ts @@ -67,11 +67,11 @@ describe('protected Antigravity account snapshots', () => { const service = new AntigravityAccountService(store, h.backend) h.setNative(paddedCredential('new-account', 60000)) await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) expect(store.read().accounts).toHaveLength(52) h.setNative(paddedCredential('large-51', 65000)) await expect(service.listAccounts()).rejects.toThrow('could not be saved') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) expect(store.read().accounts).toHaveLength(52) }) @@ -115,7 +115,7 @@ describe('protected Antigravity account snapshots', () => { 'Protected secret storage' ) expect(() => store.read()).toThrow('Protected secret storage') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) } ) diff --git a/src/main/browser/browser-manager-annotation-bridge.test.ts b/src/main/browser/browser-manager-annotation-bridge.test.ts index 2d9aa5e50fe..a38cead49c9 100644 --- a/src/main/browser/browser-manager-annotation-bridge.test.ts +++ b/src/main/browser/browser-manager-annotation-bridge.test.ts @@ -51,6 +51,28 @@ const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = { token: 'annotationviewporttoken' } +const MARKER_OPTIONS: BrowserAnnotationViewportBridgeOptions = { + ...BRIDGE_OPTIONS, + markers: [ + { + id: 'historical-document-marker', + index: 2, + isFixed: false, + rectPage: { x: 80, y: 120, width: 100, height: 40 }, + rectViewport: { x: 80, y: 120, width: 100, height: 40 } + } + ] +} + +function deferGuestInjection(guest: Record): () => void { + let release = (): void => {} + const gate = new Promise((resolve) => { + release = resolve + }) + guest.executeJavaScriptInIsolatedWorld = vi.fn(() => gate) + return release +} + function registerPage(pageId: string, guest: Record): void { webContentsFromIdMock.mockReturnValue(guest) browserManager.attachGuestPolicies(guest as never) @@ -141,6 +163,161 @@ describe('browserManager.setAnnotationViewportBridge', () => { expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) }) + it.each([false, true])( + 'skips retired queued markers after invalidation (replacement guest: %s)', + async (replaceGuest) => { + const { guest: firstGuest } = makeGuest(5201) + const release = deferGuestInjection(firstGuest) + registerPage('tab-retired-markers', firstGuest) + const resolveGuest = resolveFromRegistry('tab-retired-markers') + const inFlight = browserManager.setAnnotationViewportBridge( + 'tab-retired-markers', + BRIDGE_OPTIONS, + resolveGuest + ) + await flushViewportOps() + const retired = browserManager.setAnnotationViewportBridge( + 'tab-retired-markers', + MARKER_OPTIONS, + resolveGuest + ) + const { guest: replacementGuest } = makeGuest(5202) + if (replaceGuest) { + registerPage('tab-retired-markers', replacementGuest) + } + const cleared = browserManager.setAnnotationViewportBridge( + 'tab-retired-markers', + { ...BRIDGE_OPTIONS, enabled: false }, + resolveGuest + ) + + release() + await expect(inFlight).resolves.toBe(true) + await expect(retired).resolves.toBe(false) + await expect(cleared).resolves.toBe(true) + const currentGuest = replaceGuest ? replacementGuest : firstGuest + expect(currentGuest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalledWith( + expect.any(Number), + [expect.objectContaining({ code: expect.stringContaining('historical-document-marker') })], + false + ) + expect(currentGuest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith( + expect.any(Number), + [expect.objectContaining({ code: expect.stringContaining('const enabled = false;') })], + false + ) + } + ) + + it('keeps an in-flight injection and applies the latest fresh markers and token', async () => { + const { guest } = makeGuest(5203) + const release = deferGuestInjection(guest) + const resolveGuest = resolveFromRegistry('tab-fresh-markers') + registerPage('tab-fresh-markers', guest) + const inFlight = browserManager.setAnnotationViewportBridge( + 'tab-fresh-markers', + MARKER_OPTIONS, + resolveGuest + ) + await flushViewportOps() + const cleared = browserManager.setAnnotationViewportBridge( + 'tab-fresh-markers', + { ...BRIDGE_OPTIONS, enabled: false }, + resolveGuest + ) + const fresh = browserManager.setAnnotationViewportBridge( + 'tab-fresh-markers', + { + ...MARKER_OPTIONS, + token: 'remintedpreviewtoken', + markers: MARKER_OPTIONS.markers.map((marker) => ({ ...marker, id: 'fresh-preview-marker' })) + }, + resolveGuest + ) + + release() + await expect(inFlight).resolves.toBe(true) + await expect(cleared).resolves.toBe(false) + await expect(fresh).resolves.toBe(true) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith( + expect.any(Number), + [ + expect.objectContaining({ + code: expect.stringContaining('fresh-preview-marker') + }) + ], + false + ) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenLastCalledWith( + expect.any(Number), + [expect.objectContaining({ code: expect.stringContaining('remintedpreviewtoken') })], + false + ) + }) + + it('continues the queue after an in-flight injection fails', async () => { + const { guest } = makeGuest(5204) + let rejectInjection = (): void => {} + const gate = new Promise((_resolve, reject) => { + rejectInjection = () => reject(new Error('retired document')) + }) + guest.executeJavaScriptInIsolatedWorld = vi + .fn() + .mockReturnValueOnce(gate) + .mockResolvedValue(true) + registerPage('tab-failed-injection', guest) + const resolveGuest = resolveFromRegistry('tab-failed-injection') + const inFlight = browserManager.setAnnotationViewportBridge( + 'tab-failed-injection', + MARKER_OPTIONS, + resolveGuest + ) + await flushViewportOps() + const cleared = browserManager.setAnnotationViewportBridge( + 'tab-failed-injection', + { ...BRIDGE_OPTIONS, enabled: false }, + resolveGuest + ) + + rejectInjection() + await expect(inFlight).resolves.toBe(false) + await expect(cleared).resolves.toBe(true) + await expect( + browserManager.setAnnotationViewportBridge( + 'tab-failed-injection', + BRIDGE_OPTIONS, + resolveGuest + ) + ).resolves.toBe(true) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(3) + }) + + it('does not run a queued marker request after page teardown removes its chain', async () => { + const { guest } = makeGuest(5205) + const release = deferGuestInjection(guest) + registerPage('tab-closed-queue', guest) + const resolveGuest = resolveFromRegistry('tab-closed-queue') + const inFlight = browserManager.setAnnotationViewportBridge( + 'tab-closed-queue', + BRIDGE_OPTIONS, + resolveGuest + ) + await flushViewportOps() + const retired = browserManager.setAnnotationViewportBridge( + 'tab-closed-queue', + MARKER_OPTIONS, + resolveGuest + ) + browserManager.unregisterGuest('tab-closed-queue') + + release() + await expect(inFlight).resolves.toBe(true) + await expect(retired).resolves.toBe(false) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) + expect(browserManager.getGuestWebContentsId('tab-closed-queue')).toBeNull() + }) + // Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry // is the one that can see the guest is gone. The bridge only reports the refusal. it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => { diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts index 64e50e5ea48..6f7e7c2b281 100644 --- a/src/main/browser/browser-manager-viewport.ts +++ b/src/main/browser/browser-manager-viewport.ts @@ -77,7 +77,13 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() const next = prev .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) + .then(() => { + // A newer document invalidation retires geometry still waiting in the queue. + if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) !== next) { + return false + } + return this.doSetAnnotationViewportBridgeImpl(options, resolveGuest) + }) this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) try { return await next diff --git a/src/main/claude/claude-child-work-evidence-retention.test.ts b/src/main/claude/claude-child-work-evidence-retention.test.ts index 2ba58138c24..613c2694ac7 100644 --- a/src/main/claude/claude-child-work-evidence-retention.test.ts +++ b/src/main/claude/claude-child-work-evidence-retention.test.ts @@ -8,6 +8,7 @@ import { deriveToolInputPreview } from '../../shared/agent-hook-listener/tool-in import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { claudeChildOperation, drainClaudeChildWork } from './claude-child-work-evidence' +import { ClaudePromptRegistry } from './claude-prompt-registry' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeToolResults, @@ -85,7 +86,11 @@ describe('Claude child operation output retention', () => { translator.handle({ type: 'message', sessionId: 'orca', message, observedAt: 1_000 }) const join = vi.spyOn(Array.prototype, 'join') const evidence = drainClaudeChildWork( - { childWork: new ClaudeChildWorkDecoder(), translator }, + { + childWork: new ClaudeChildWorkDecoder(), + prompts: new ClaudePromptRegistry(), + translator + }, message, 1_000 ) diff --git a/src/main/claude/claude-real-cli-test-gate.test.ts b/src/main/claude/claude-real-cli-test-gate.test.ts index 508fd6af08e..df9b0ad936a 100644 --- a/src/main/claude/claude-real-cli-test-gate.test.ts +++ b/src/main/claude/claude-real-cli-test-gate.test.ts @@ -46,6 +46,25 @@ describe('resolveRealClaudeCliGate', () => { expect(runClaude.mock.calls).toEqual([[['--version']], [['auth', 'status', '--json']]]) }) + it('reads the final account report after the CLI configuration warning', () => { + const warning = [ + 'Claude configuration file not found at: /home/dev/.claude/.claude.json', + 'A backup file exists at: /home/dev/.claude/backups/.claude.json.backup.123', + 'You can manually restore it by running: cp /home/dev/.claude/backups/.claude.json.backup.123 /home/dev/.claude/.claude.json', + '' + ].join('\n') + const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult => + args[0] === '--version' + ? { status: 0, stdout: '2.1.0\n' } + : { status: 0, stdout: warning + warning + JSON.stringify({ loggedIn: true }) } + ) + + expect(resolveRealClaudeCliGate({ [REAL_CLAUDE_CLI_TEST_ENV]: '1' }, runClaude)).toEqual({ + skipReason: null, + authStatus: { loggedIn: true } + }) + }) + it('still skips when opted in but no claude binary answers', () => { const runClaude = vi.fn((): ClaudeCliProbeResult => ({ status: null, stdout: '' })) @@ -57,7 +76,14 @@ describe('resolveRealClaudeCliGate', () => { it.each([ ['a failed auth probe', { status: 1, stdout: '' }], - ['unparseable auth output', { status: 0, stdout: 'not json' }] + ['unparseable auth output', { status: 0, stdout: 'not json' }], + ['malformed JSON', { status: 0, stdout: '{"loggedIn":' }], + ['trailing output', { status: 0, stdout: '{"loggedIn":true}\nnot json' }], + ['two JSON objects', { status: 0, stdout: '{"loggedIn":false}\n{"loggedIn":true}' }], + ['another JSON value before an object', { status: 0, stdout: 'true\n{"loggedIn":true}' }], + ['an array before an object', { status: 0, stdout: '[]\n{"loggedIn":true}' }], + ['a JSON array', { status: 0, stdout: '[{"loggedIn":true}]' }], + ['an unrelated JSON value', { status: 0, stdout: 'true' }] ])('runs with no account report after %s', (_label, authResult) => { const runClaude = vi.fn((args: readonly string[]): ClaudeCliProbeResult => args[0] === '--version' ? { status: 0, stdout: '2.1.0\n' } : authResult diff --git a/src/main/claude/claude-real-cli-test-gate.ts b/src/main/claude/claude-real-cli-test-gate.ts index de25ef87bf4..1c6a609ed79 100644 --- a/src/main/claude/claude-real-cli-test-gate.ts +++ b/src/main/claude/claude-real-cli-test-gate.ts @@ -13,13 +13,26 @@ export type RealClaudeCliGate = { } function parseAuthStatus(stdout: string): RealClaudeAuthStatus | null { + const start = stdout.search(/[[{]/) + if (start === -1) { + return null + } + // CLI warnings may precede the report, but another JSON value makes it ambiguous. + for (const line of stdout.slice(0, start).split('\n')) { + try { + JSON.parse(line) + return null + } catch { + continue + } + } let parsed: unknown try { - parsed = JSON.parse(stdout) + parsed = JSON.parse(stdout.slice(start)) } catch { return null } - if (!parsed || typeof parsed !== 'object') { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null } const status: RealClaudeAuthStatus = {} diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts index 869a8390808..a1540cf7720 100644 --- a/src/main/claude/claude-structured-journal-prompts.ts +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -93,7 +93,8 @@ export class ClaudeJournalPrompts { const append = (identity: AgentJournalItemIdentity, body: ClaudeJournalPrompt['body']) => { const options = { ...producer, turnScope } if (this.deps.sink.tryAppendItem) { - admitted &&= this.deps.sink.tryAppendItem(identity, body, options).accepted + const admission = this.deps.sink.tryAppendItem(identity, body, options) + admitted &&= admission.accepted } else { this.deps.sink.appendItem(identity, body, options) } diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 93f38f221ee..145d0db7134 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -255,89 +255,129 @@ describe('Claude live prompt ownership', () => { ).toBe(false) }) - it('rejects a grouped prompt batch without partially revising its first row', () => { - const tombstones: string[] = [] - const appendTombstone = vi.fn( - (identity: Parameters[0]) => { - tombstones.push(agentJournalItemKey(identity)) - } - ) - let rowAdmission = 0 - const tryAppendTombstone = vi.fn( - (identity: Parameters[0]) => { - rowAdmission += 1 - if (rowAdmission === 2) { + it.each([0, 1, null] as const)( + 'rejects a grouped prompt batch without partial revision after row %s admission refusal', + async (refusedAt) => { + const tombstones: string[] = [] + const appendTombstone = vi.fn( + (identity: Parameters[0]) => { + tombstones.push(agentJournalItemKey(identity)) + } + ) + let rowAdmission = 0 + const tryAppendTombstone = vi.fn( + (identity: Parameters[0]) => { + rowAdmission += 1 + if (rowAdmission === 2) { + return { accepted: false as const, reason: 'backpressure' as const } + } + appendTombstone(identity) + return { accepted: true as const } + } + ) + const tryAppendLifecycleBatch = vi.fn( + ( + _settlementId: string, + mutations: Parameters< + NonNullable + >[1] + ) => { + expect(mutations[1]).toMatchObject({ + kind: 'item', + body: { resolution: { state: 'cancelled' } } + }) return { accepted: false as const, reason: 'backpressure' as const } } - appendTombstone(identity) - return { accepted: true as const } + ) + const admittedRows = new Map() + let admissionAttempts = 0 + const tryAppendItem = vi.fn( + ( + identity: Parameters[0], + body: AgentJournalItemBody + ) => { + if (admissionAttempts++ === refusedAt) { + return { accepted: false as const, reason: 'backpressure' as const } + } + admittedRows.set(agentJournalItemKey(identity), body) + return { accepted: true as const } + } + ) + const bindPromptItemId = vi.fn() + const written = vi.fn(async () => ({ ok: true as const })) + const prompts = new ClaudeJournalPrompts({ + sink: { + appendItem: () => {}, + tryAppendItem, + appendTombstone, + tryAppendTombstone, + tryAppendLifecycleBatch, + publish: () => {}, + written + }, + bindPromptItemId, + producerOf: () => ({ agentId: 'agent-grouped', producerKind: 'agent' }), + questionItems: (input) => { + const item = claudeQuestionItems(input)[0] + return item + ? [ + { + ...item, + identity: { provider: 'orca', clientMessageId: 'group:first' } + }, + { + ...item, + identity: { provider: 'orca', clientMessageId: 'group:second' } + } + ] + : [] + }, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) + const prompt: ClaudePendingPrompt = { + requestId: 'grouped-request', + promptKey: 'grouped-request', + toolUseId: 'tool-grouped', + toolName: 'AskUserQuestion', + kind: 'question', + input: { + questions: [ + { question: 'First?', options: [{ label: 'Yes' }] }, + { question: 'Second?', options: [{ label: 'No' }] } + ] + }, + suggestions: [], + questionIds: ['First?', 'Second?'], + settle: vi.fn() } - ) - const tryAppendLifecycleBatch = vi.fn( - ( - _settlementId: string, - mutations: Parameters< - NonNullable - >[1] - ) => { - expect(mutations[1]).toMatchObject({ - kind: 'item', - body: { resolution: { state: 'cancelled' } } - }) - return { accepted: false as const, reason: 'backpressure' as const } - } - ) - const prompts = new ClaudeJournalPrompts({ - sink: { - appendItem: () => {}, - appendTombstone, - tryAppendTombstone, - tryAppendLifecycleBatch, - publish: () => {} - }, - questionItems: (input) => { - const item = claudeQuestionItems(input)[0] - return item - ? [ - { - ...item, - identity: { provider: 'orca', clientMessageId: 'group:first' } - }, - { - ...item, - identity: { provider: 'orca', clientMessageId: 'group:second' } - } - ] - : [] - }, - turnScope: () => AGENT_JOURNAL_THREAD_SCOPE - }) - const prompt: ClaudePendingPrompt = { - requestId: 'grouped-request', - promptKey: 'grouped-request', - toolUseId: 'tool-grouped', - toolName: 'AskUserQuestion', - kind: 'question', - input: { - questions: [ - { question: 'First?', options: [{ label: 'Yes' }] }, - { question: 'Second?', options: [{ label: 'No' }] } - ] - }, - suggestions: [], - questionIds: ['First?', 'Second?'], - settle: vi.fn() - } - prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) + prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) + await prompts.whenWritten(prompt.promptKey) - expect(prompts.cancel(prompt.promptKey)).toEqual({ - accepted: false, - reason: 'backpressure' - }) - expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce() - expect(tryAppendTombstone).not.toHaveBeenCalled() - expect(tombstones).toEqual([]) - }) + expect(tryAppendItem.mock.calls.map(([identity]) => agentJournalItemKey(identity))).toEqual([ + 'orca:group%3Afirst', + 'orca:group%3Asecond' + ]) + expect([...admittedRows.keys()]).toEqual( + ['orca:group%3Afirst', 'orca:group%3Asecond'].filter((_, index) => index !== refusedAt) + ) + expect(bindPromptItemId.mock.calls).toEqual([ + ['orca:group%3Afirst', prompt.promptKey], + ['orca:group%3Asecond', prompt.promptKey] + ]) + expect(written).toHaveBeenCalledTimes(refusedAt === null ? 1 : 0) + expect([...prompts.openCards()]).toEqual( + refusedAt === null ? [{ promptKey: prompt.promptKey, asker: 'agent-grouped' }] : [] + ) + + expect(prompts.cancel(prompt.promptKey)).toEqual({ + accepted: false, + reason: 'backpressure' + }) + expect(tryAppendLifecycleBatch).toHaveBeenCalledOnce() + expect(tryAppendTombstone).not.toHaveBeenCalled() + expect(tombstones).toEqual([]) + } + ) it('keeps every backpressured prompt cancellation retry in its owned entry', () => { let backpressured = true diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index bd88413edba..af6719938be 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, join, relative } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { buildClaudeChildProcessEnv } from './claude-child-process-environment' +import * as claudeConnection from './claude-stream-json-connection' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' @@ -11,7 +13,8 @@ import { realClaudeAuthStatus, realClaudeAvailable, realClaudeCliGate, - realClaudeCommand + realClaudeCommand, + realClaudeLaunchHome } from './claude-real-cli-availability-test-support' import { ClaudeStructuredSessionAdapter, @@ -27,13 +30,15 @@ function realAdapter( claudeConfigDir: string, events: ClaudeStructuredSessionEvent[] = [], cwd = process.cwd(), - onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] + onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'], + env = realClaudeLaunchHome().env ): ClaudeStructuredSessionAdapter { const adapter = new ClaudeStructuredSessionAdapter({ resolveLaunch: async () => ({ pathToClaudeCodeExecutable: command, options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: providerSessionId }, cwd, + env, claudeConfigDir, providerSessionId, resumeLeafUuid: null, @@ -81,6 +86,53 @@ async function waitForResolvedTranscript( } } +describe('real CLI fixture authentication isolation', () => { + it.each(['authenticated', 'signed-out'] as const)( + 'keeps the %s launch environment independent of inherited credentials', + async (mode) => { + const authKeys = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'] as const + for (const key of authKeys) { + vi.stubEnv(key, 'unusable-fixture-auth-value') + } + let childEnv: Record | undefined + const open = vi + .spyOn(claudeConnection, 'openClaudeStreamJsonConnection') + .mockImplementation(async (launch) => { + childEnv = buildClaudeChildProcessEnv(launch.env) + throw new Error('fixture stopped before spawning a CLI') + }) + const providerSessionId = randomUUID() + const adapter = realAdapter( + providerSessionId, + join(tmpdir(), 'orca-synthetic-signed-out'), + [], + process.cwd(), + undefined, + mode === 'signed-out' ? {} : undefined + ) + try { + await expect( + adapter.acquire({ + identity: identity(providerSessionId), + fence: 1, + spawnToken: 'fixture' + }) + ).rejects.toThrow('fixture stopped before spawning a CLI') + expect(open).toHaveBeenCalledOnce() + for (const key of authKeys) { + expect(childEnv?.[key]).toBe( + mode === 'authenticated' ? 'unusable-fixture-auth-value' : undefined + ) + } + } finally { + await adapter.closeAll() + open.mockRestore() + vi.unstubAllEnvs() + } + } + ) +}) + describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { it.skipIf(!realClaudeAuthenticated)( 'proves a pre-minted session before the first user message', @@ -442,7 +494,14 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const claudeConfigDir = await mkdtemp(join(tmpdir(), 'orca-claude-no-auth-')) const providerSessionId = randomUUID() const events: ClaudeStructuredSessionEvent[] = [] - const adapter = realAdapter(providerSessionId, claudeConfigDir, events) + const adapter = realAdapter( + providerSessionId, + claudeConfigDir, + events, + process.cwd(), + undefined, + {} + ) try { await adapter.acquire({ diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index 727732dd64c..7440b1a9ad8 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -27,6 +27,7 @@ describe('prependOrcaCliDirToChildPath', () => { platform: 'linux' }) expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR) expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({ userDataPath: USER_DATA }) @@ -44,13 +45,14 @@ describe('prependOrcaCliDirToChildPath', () => { it('leaves packaged Linux PATH untouched when no shim could be written', () => { shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null) - const env: Record = { PATH: '/usr/bin' } + const env: Record = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/old-host/cli' } prependOrcaCliDirToChildPath(env, { isPackaged: true, userDataPath: USER_DATA, platform: 'linux' }) expect(env.PATH).toBe('/usr/bin') + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() }) it('leads packaged macOS PATH with the bundled CLI dir', () => { @@ -62,11 +64,16 @@ describe('prependOrcaCliDirToChildPath', () => { platform: 'darwin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe(join(RESOURCES, 'bin')) expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() }) it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => { - const env: Record = { Path: 'C:\\Windows\\System32' } + const env: Record = { + Path: 'C:\\Windows\\System32', + ORCA_CLI_BIN_DIR: '/parent-host/cli', + ORCA_WSL_CLI_DIR: '/guest/orca/bin' + } prependOrcaCliDirToChildPath(env, { isPackaged: true, userDataPath: USER_DATA, @@ -75,6 +82,8 @@ describe('prependOrcaCliDirToChildPath', () => { }) expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`) expect(env.PATH).toBeUndefined() + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() + expect(env.ORCA_WSL_CLI_DIR).toBe('/guest/orca/bin') }) it('leaves a packaged darwin/win32 PATH alone with no resources root', () => { @@ -101,6 +110,9 @@ describe('prependOrcaCliDirToChildPath', () => { platform }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe( + platform === 'win32' ? undefined : join(USER_DATA, 'cli', 'bin') + ) expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() }) diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index 2a8136c367f..a15943827cc 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -39,12 +39,16 @@ export function prependOrcaCliDirToChildPath( opts: OrcaCliChildPathOptions ): string | null { const platform = opts.platform ?? process.platform + delete env.ORCA_CLI_BIN_DIR // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test // drives a foreign platform through the seam. const pathDelimiter = platform === 'win32' ? ';' : delimiter // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. if (!opts.isPackaged) { const devCliBin = join(opts.userDataPath, 'cli', 'bin') + if (platform !== 'win32') { + env.ORCA_CLI_BIN_DIR = devCliBin + } const inheritedPath = readInheritedPath(env, platform) // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. env[resolvePathEnvKey(env, platform)] = inheritedPath @@ -55,6 +59,7 @@ export function prependOrcaCliDirToChildPath( // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) if (shimDir) { + env.ORCA_CLI_BIN_DIR = shimDir const inheritedEntries = readInheritedPath(env, platform) .split(pathDelimiter) .filter((entry) => entry.length > 0 && entry !== shimDir) @@ -64,6 +69,9 @@ export function prependOrcaCliDirToChildPath( } else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) { // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. const bundledCliBin = join(opts.resourcesPath, 'bin') + if (platform === 'darwin') { + env.ORCA_CLI_BIN_DIR = bundledCliBin + } const inheritedPath = readInheritedPath(env, platform) env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${bundledCliBin}${pathDelimiter}${inheritedPath}` diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts index 085777fdf8c..fc8f7b815a7 100644 --- a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts +++ b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts @@ -88,6 +88,49 @@ describe('legacy shared Codex config compatibility', () => { expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth) }) + it('keeps an Orca-added MCP server while settings and trust still refresh', () => { + const baselinePath = join(sharedRuntimeHome, '.orca-config-settings-baseline.json') + const baseline = JSON.stringify({ version: 3, settings: {}, mcpServers: [] }) + writeFileSync(baselinePath, baseline) + writeFileSync( + join(systemCodexHome, 'config.toml'), + ['model = "canonical"', '', '[projects."/revoked"]', 'trust_level = "untrusted"', ''].join( + '\n' + ) + ) + writeFileSync( + join(sharedRuntimeHome, 'config.toml'), + [ + 'model = "stale"', + '', + '[projects."/trusted-in-orca"]', + 'trust_level = "trusted"', + '', + '[projects."/revoked"]', + 'trust_level = "trusted"', + '', + '[hooks.state."orca:stop:0:0"]', + 'enabled = true', + '', + '[mcp_servers.demo-mcp]', + 'command = "demo"', + '' + ].join('\n') + ) + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8') + expect(sharedConfig).toContain('model = "canonical"') + expect(sharedConfig).not.toContain('model = "stale"') + expect(sharedConfig).toContain('[projects."/trusted-in-orca"]\ntrust_level = "trusted"') + expect(sharedConfig).toContain('[projects."/revoked"]\ntrust_level = "untrusted"') + expect(sharedConfig).not.toContain('[projects."/revoked"]\ntrust_level = "trusted"') + expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]') + expect(sharedConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "demo"') + expect(readFileSync(baselinePath, 'utf-8')).toBe(baseline) + }) + it('does not delete config when the canonical source is transiently missing', () => { const staleConfig = 'model_provider = "stale-provider"\n' writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8') diff --git a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts index 2c8a86fd28b..44172582e21 100644 --- a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts +++ b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts @@ -21,6 +21,20 @@ beforeEach(() => { afterEach(() => rmSync(root, { recursive: true, force: true })) +const BASELINE_FILE = '.orca-config-settings-baseline.json' + +type StoredBaselineFixture = { + version: 1 | 3 + settings: Record + mcpServers?: string[] +} + +function writeBaseline(baseline: StoredBaselineFixture): string { + const serialized = JSON.stringify(baseline) + writeFileSync(join(runtimeHomePath, BASELINE_FILE), serialized) + return serialized +} + describe('canonical MCP ownership during config mirroring', () => { it('does not duplicate a server defined inline in the canonical MCP table', () => { writeFileSync( @@ -130,6 +144,60 @@ describe('MCP ownership migration', () => { ) }) + it('keeps the retained shared home canonical under a pre-ownership baseline', () => { + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + writeBaseline({ version: 1, settings: {} }) + + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + }) + + it('keeps Orca-only servers in the retained shared home and drops ones removed from ~/.codex', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + [ + '[mcp_servers.demo-mcp]', + 'command = "orca-only"', + '[mcp_servers.removed]', + 'command = "mirrored"', + '[mcp_servers.kept]', + 'command = "stale"', + '' + ].join('\n') + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + 'model = "system"\n[mcp_servers.kept]\ncommand = "system"\n' + ) + const baseline = writeBaseline({ version: 3, settings: {}, mcpServers: ['removed', 'kept'] }) + + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "orca-only"') + expect(runtimeConfig).not.toContain('[mcp_servers.removed]') + expect(runtimeConfig).toContain('[mcp_servers.kept]\ncommand = "system"') + expect(runtimeConfig).not.toContain('"stale"') + expect(readFileSync(join(runtimeHomePath, BASELINE_FILE), 'utf-8')).toBe(baseline) + }) + + it('leaves the retained shared home untouched when its baseline cannot be read', () => { + const runtimeConfig = 'model = "retained"\n[mcp_servers.demo-mcp]\ncommand = "orca-only"\n' + writeFileSync(join(runtimeHomePath, 'config.toml'), runtimeConfig) + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + mkdirSync(join(runtimeHomePath, BASELINE_FILE)) + + expect(() => + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + ).toThrow() + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(runtimeConfig) + }) + it('tracks commented CRLF names so their later removal remains authoritative', () => { writeFileSync( join(runtimeHomePath, 'config.toml'), diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index a97a8b9644c..74f8b2f0b53 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -16,7 +16,7 @@ import { type CodexSettingsPromotionHomes, type CodexSettingsPromotionPlan } from './config-settings-promotion' -import { readCodexSettingsBaseline } from './config-settings-baseline' +import { observeCodexSettingsBaseline, readCodexSettingsBaseline } from './config-settings-baseline' import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall' import { preserveRuntimeConflictValues } from './codex-config-settings-preservation' import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' @@ -161,15 +161,13 @@ export function syncSystemConfigIntoLegacySharedCodexHome( let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? '' if (rawSystemConfig.trim() !== '') { const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) - // The retired home has no ownership baseline; its entire MCP root stays canonical. mirroredRuntimeConfig = runtimeConfigBeforeMirror !== null ? mergeSystemCodexConfigIntoRuntime( runtimeConfigBeforeMirror, prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir), sourceConfigDir, - new Set(), - true + ...readLegacySharedHomeMcpOwnership(homes.runtimeHomePath) ) : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) } @@ -186,6 +184,24 @@ export function syncSystemConfigIntoLegacySharedCodexHome( writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig) } +/** + * Why: MCP servers added from an Orca terminal exist only in the retired home, + * so its last mirror's baseline decides which ones ~/.codex owns. With no + * baseline the whole root stays canonical, as before; an unreadable one throws + * rather than guess. Read-only: this one-way refresh never advances it. + */ +function readLegacySharedHomeMcpOwnership( + runtimeHomePath: string +): [mirroredMcpServerNames: ReadonlySet, mirroredMcpServerRoot: boolean] { + const observation = observeCodexSettingsBaseline(runtimeHomePath) + if (observation.kind === 'indeterminate') { + throw new Error('Codex settings baseline could not be read') + } + return observation.kind === 'present' + ? [observation.baseline.mcpServers, observation.baseline.mcpServerRoot] + : [new Set(), true] +} + type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } | { status: 'refused-indeterminate'; error: unknown } diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 4dcd49e9645..517f9effb90 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -10,6 +10,7 @@ import { } from '../runtime/structured-worker-identity' const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/ +const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/ // The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global. const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/ @@ -23,7 +24,11 @@ describe('buildCodexStructuredChildEnvironment', () => { cwd: '/worktree', codexHome: '/pinned/home', resumeThreadId: null, - env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' } + env: { + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home', + ORCA_CLI_BIN_DIR: '/inherited/unowned-cli' + } }, 'spawn-token', 'session-not-a-worker' @@ -35,6 +40,9 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) @@ -57,6 +65,9 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -97,6 +108,7 @@ const ENV_REPORTING_APP_SERVER = String.raw` result: { sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, cliCommand: process.env.ORCA_CLI_COMMAND ?? null, + cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null, path: process.env.PATH ?? process.env.Path ?? null } }) @@ -135,6 +147,7 @@ describe('the spawned Codex child', () => { await expect(connection.request('test/env')).resolves.toEqual({ sessionId, cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), + cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR), path: expect.stringMatching(DEV_CLI_BIN_FIRST) }) } finally { diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 70b2ecd7147..9276d670b4c 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -38,6 +38,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index 6ae5c7f4c63..bf2e2545102 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -1,4 +1,5 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' @@ -35,6 +36,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${ORCA_CLI_POSIX_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/daemon/daemon-idle-inventory-budget.test.ts b/src/main/daemon/daemon-idle-inventory-budget.test.ts new file mode 100644 index 00000000000..e83b6a122ce --- /dev/null +++ b/src/main/daemon/daemon-idle-inventory-budget.test.ts @@ -0,0 +1,323 @@ +import './mock-descendant-sweep' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type * as CryptoModule from 'node:crypto' +import { DaemonServer } from './daemon-server' +import { DaemonClient } from './client' +import { DaemonClientConnections } from './daemon-client-connections' +import { TerminalHost } from './terminal-host' +import { Session } from './session' +import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' +import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import { getDaemonSocketPath } from './daemon-spawner' +import type { SubprocessHandle } from './session-subprocess-handle' + +type MockSubprocess = SubprocessHandle & { exit: () => void } +const seed = vi.hoisted(() => ({ uuid: 0 })) +vi.mock('node:crypto', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + randomUUID: () => `00000000-0000-4000-8000-${String(++seed.uuid).padStart(12, '0')}` + } +}) +const clients: DaemonClient[] = [] +const processes: MockSubprocess[] = [] +let dir = '' +let server: DaemonServer | undefined + +async function waitFor(predicate: () => boolean): Promise { + const deadline = performance.now() + 2_000 + while (!predicate()) { + if (performance.now() >= deadline) { + throw new Error('Daemon event did not settle') + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +function spawn(): MockSubprocess { + let exited: ((code: number) => void) | undefined + const proc: MockSubprocess = { + pid: 90_001, + getForegroundProcess: () => null, + write: vi.fn(), + resize: vi.fn(), + kill: () => exited?.(0), + forceKill: () => exited?.(137), + terminateOwnedTree: () => 'unavailable', + signal: vi.fn(), + onData: vi.fn(), + onExit: (callback) => { + exited = callback + }, + dispose: vi.fn(), + exit: () => exited?.(0) + } + processes.push(proc) + return proc +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-idle-inventory-')) + clients.length = 0 + processes.length = 0 + seed.uuid = 0 +}) + +afterEach(async () => { + for (const process of processes) { + process.exit() + } + for (const client of clients) { + client.disconnect() + } + await server?.shutdown() + vi.restoreAllMocks() + rmSync(dir, { recursive: true, force: true }) +}) + +it('keeps live sessions and exact inventories while visitors repeatedly disconnect', async () => { + const socketPath = getDaemonSocketPath(dir) + const tokenPath = join(dir, 'daemon.token') + const idle = vi.fn() + const getClient = vi.spyOn(DaemonClientConnections.prototype, 'get') + const create = vi.spyOn(TerminalHost.prototype, 'createOrAttach') + server = new DaemonServer({ socketPath, tokenPath, spawnSubprocess: spawn, onIdleShutdown: idle }) + await server.start() + const owner = new DaemonClient({ socketPath, tokenPath }) + clients.push(owner) + await owner.ensureConnected() + for (let index = 0; index < 16; index++) { + await owner.request('createOrAttach', { + sessionId: `session-${index}`, + cols: 80, + rows: 24, + ...(index < 8 + ? { + agentSessionEnsure: { + claim: { + digestVersion: 1, + keyId: `key${index}`, + identityDigest: 'a'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' + }, + surface: { + worktreeId: `folder${index}`, + tabId: '11111111-1111-4111-8111-111111111111', + leafId: '22222222-2222-4222-8222-222222222222', + terminalHandle: `term_idle${index}` + } + } + } + : {}) + }) + } + const connections = getClient.mock.contexts[0] + if (!(connections instanceof DaemonClientConnections)) { + throw new Error('Actual server connections were not used') + } + const before = await owner.request('listSessions', undefined) + const expected = structuredClone(before) + if ( + typeof before !== 'object' || + before === null || + !('sessions' in before) || + !Array.isArray(before.sessions) + ) { + throw new Error('Initial inventory was malformed') + } + const first = before.sessions[0] + if ( + typeof first !== 'object' || + first === null || + !('agentSessionOwners' in first) || + !Array.isArray(first.agentSessionOwners) || + !first.agentSessionOwners.every(isAgentSessionOwnerBinding) + ) { + throw new Error('Initial owners were malformed') + } + const priorOwner = first.agentSessionOwners[0] + priorOwner.claim.keyId = 'mutated-client-copy' + priorOwner.surface.terminalHandle = 'term_mutated' + const inventory = vi.spyOn(TerminalHost.prototype, 'listSessions') + const owners = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty') + const sizes = vi.spyOn(Session.prototype, 'getAppliedSize') + const cwds = vi.spyOn(Session.prototype, 'getCwd') + const originalAlive = Object.getOwnPropertyDescriptor(Session.prototype, 'isAlive')?.get + if (!originalAlive) { + throw new Error('Session alive getter is missing') + } + const alive = vi.spyOn(Session.prototype, 'isAlive', 'get') + for (let iteration = 0; iteration < 10; iteration++) { + const visitor = new DaemonClient({ socketPath, tokenPath }) + clients.push(visitor) + await visitor.ensureConnected() + expect(await visitor.request('ping', undefined)).toEqual({ pong: true }) + visitor.disconnect() + await waitFor(() => connections.size === 1 && connections.transportCount === 2) + expect(idle).not.toHaveBeenCalled() + } + const calls = inventory.mock.calls.length + const sizeCalls = sizes.mock.calls.length + const cwdCalls = cwds.mock.calls.length + const readSessionIds = (): string[] => + alive.mock.contexts.map((session) => { + if (!(session instanceof Session)) { + throw new Error('Actual Session was not read') + } + return session.sessionId + }) + const aliveReads = readSessionIds() + const ownerCalls = owners.mock.calls.length + const copiedOwners = owners.mock.results.reduce((total, result) => { + const value: unknown = result.value + if (!Array.isArray(value)) { + throw new Error('Owner result was not an array') + } + return total + value.length + }, 0) + const after = await owner.request('listSessions', undefined) + expect(after).toEqual(expected) + expect(JSON.stringify(after)).toBe(JSON.stringify(expected)) + expect(after).not.toBe(before) + if ( + typeof after !== 'object' || + after === null || + !('sessions' in after) || + !Array.isArray(after.sessions) + ) { + throw new Error('Fresh inventory was malformed') + } + const fresh = after.sessions[0] + if ( + typeof fresh !== 'object' || + fresh === null || + !('agentSessionOwners' in fresh) || + !Array.isArray(fresh.agentSessionOwners) || + !fresh.agentSessionOwners.every(isAgentSessionOwnerBinding) + ) { + throw new Error('Fresh owners were malformed') + } + expect(fresh.agentSessionOwners).not.toBe(first.agentSessionOwners) + expect(fresh.agentSessionOwners[0]).not.toBe(priorOwner) + expect(fresh.agentSessionOwners[0].claim).not.toBe(priorOwner.claim) + expect(fresh.agentSessionOwners[0].surface).not.toBe(priorOwner.surface) + expect(idle).not.toHaveBeenCalled() + const host = create.mock.contexts[0] + if (!(host instanceof TerminalHost)) { + throw new Error('Actual host was not used') + } + const hasLive = (): boolean => { + const candidate: unknown = host + if ( + typeof candidate === 'object' && + candidate !== null && + 'hasLiveSessions' in candidate && + typeof candidate.hasLiveSessions === 'function' + ) { + const result: unknown = candidate.hasLiveSessions() + if (typeof result !== 'boolean') { + throw new Error('Live result was not boolean') + } + return result + } + return host.listSessions().length > 0 + } + const readAlive = (session: Session): boolean => { + const result: unknown = originalAlive.call(session) + if (typeof result !== 'boolean') { + throw new Error('Original alive getter was malformed') + } + return result + } + const controlReads: string[][] = [] + const failure = new Error('later-session-alive-failure') + alive.mockImplementation(function (this: Session) { + if (this.sessionId === 'session-1') { + throw failure + } + return readAlive(this) + }) + let caught: unknown + try { + hasLive() + } catch (error) { + caught = error + } + expect(caught).toBe(failure) + const sessionIds = Array.from({ length: 16 }, (_value, index) => `session-${index}`) + const deadIds = new Set(sessionIds) + alive.mockClear().mockImplementation(function (this: Session) { + return !deadIds.has(this.sessionId) && readAlive(this) + }) + expect(hasLive()).toBe(false) + controlReads.push(readSessionIds()) + deadIds.delete('session-3') + alive.mockClear() + expect(hasLive()).toBe(true) + controlReads.push(readSessionIds()) + deadIds.add('session-3') + alive.mockClear() + expect(hasLive()).toBe(false) + controlReads.push(readSessionIds()) + let entered = false + let nested: boolean | undefined + alive.mockClear().mockImplementation(function (this: Session) { + if (!entered) { + entered = true + processes[1].exit() + nested = hasLive() + } + return readAlive(this) + }) + expect(hasLive()).toBe(true) + expect(nested).toBe(true) + controlReads.push(readSessionIds()) + alive.mockRestore() + if ( + typeof expected !== 'object' || + expected === null || + !('sessions' in expected) || + !Array.isArray(expected.sessions) + ) { + throw new Error('Inventory was malformed') + } + const remaining = { + ...expected, + sessions: expected.sessions.filter( + (row: unknown) => + typeof row === 'object' && + row !== null && + 'sessionId' in row && + row.sessionId !== 'session-1' + ) + } + const afterExit = await owner.request('listSessions', undefined) + expect(afterExit).toEqual(remaining) + expect(JSON.stringify(afterExit)).toBe(JSON.stringify(remaining)) + expect(aliveReads).toEqual(Array.from({ length: 30 }, () => sessionIds).flat()) + const remainingIds = sessionIds.filter((id) => id !== 'session-1') + expect(controlReads).toEqual([ + sessionIds, + sessionIds, + sessionIds, + [ + sessionIds[0], + ...sessionIds, + sessionIds[1], + ...remainingIds, + ...remainingIds, + ...sessionIds.slice(2) + ] + ]) + expect(calls).toBe(0) + expect(ownerCalls).toBe(0) + expect(copiedOwners).toBe(0) + expect(sizeCalls).toBe(0) + expect(cwdCalls).toBe(0) +}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index ab22af24100..a917891f109 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -233,7 +233,7 @@ export class DaemonServer { } private isIdle(): boolean { - if (this.admission.inFlight > 0 || this.host.listSessions().length > 0) { + if (this.admission.inFlight > 0 || this.host.hasLiveSessions()) { return false } if (this.endpoint.lost) { diff --git a/src/main/daemon/serialize-grid-cell-descriptors.test.ts b/src/main/daemon/serialize-grid-cell-descriptors.test.ts index 490b40a1c9d..8aec878b790 100644 --- a/src/main/daemon/serialize-grid-cell-descriptors.test.ts +++ b/src/main/daemon/serialize-grid-cell-descriptors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { Terminal } from '@xterm/headless' -import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors' +import { cellDescriptor, compareBufferRows } from './serialize-grid-cell-descriptors' import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip' // Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor. @@ -78,48 +78,6 @@ function allocatingBufferRows( } describe('serialize oracle cell reuse', () => { - it.each([false, true])( - 'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)', - (conpty) => { - const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) - try { - const writes = [ - 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n', - '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n', - '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n', - 'scroll1\r\nscroll2\r\nscroll3\r\n', - '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m', - '\x1b[?1049l\x1b[2J\x1b[Hclear' - ] - for (const data of writes) { - writeTerminal(terminal, data) - for (const buffer of [ - terminal.buffer.normal, - terminal.buffer.alternate, - terminal.buffer.active - ]) { - expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual( - allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2) - ) - } - terminal.resize(terminal.cols === 14 ? 9 : 14, 4) - expect( - bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols) - ).toEqual( - allocatingBufferRows( - terminal.buffer.active, - 0, - terminal.buffer.active.length, - terminal.cols - ) - ) - } - } finally { - terminal.dispose() - } - } - ) - it('preserves the order of every text flag combination while reloading a plain cell', () => { const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) try { @@ -167,50 +125,11 @@ describe('serialize oracle cell reuse', () => { const scratch = terminal.buffer.active.getNullCell() expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED') expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4)) - expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual( - allocatingBufferRows(terminal.buffer.active, 0, 1, 4) - ) } finally { terminal.dispose() } }) - it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => { - const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 }) - try { - writeTerminal(terminal, '\x1b[1mA\x1b[0m B界') - const buffer = terminal.buffer.active - const scratch = buffer.getNullCell() - const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch) - const getLine = buffer.getLine.bind(buffer) - const loaded: unknown[] = [] - vi.spyOn(buffer, 'getLine').mockImplementation((y) => { - const line = getLine(y) - if (line) { - const getCell = line.getCell.bind(line) - vi.spyOn(line, 'getCell').mockImplementation((x, cell) => { - loaded.push(cell) - return getCell(x, cell) - }) - } - return line - }) - const expected = allocatingBufferRows(buffer, 0, 2, 4) - loaded.length = 0 - const actual = bufferRows(buffer, 0, 2, 4) - expect(actual).toEqual(expected) - expect(allocate).toHaveBeenCalledTimes(1) - expect(loaded).toHaveLength(8) - expect(loaded.every((cell) => cell === scratch)).toBe(true) - writeTerminal(terminal, '\x1b[2J\x1b[Hnew') - bufferRows(buffer, 0, 2, 4) - expect(actual).toEqual(expected) - } finally { - terminal.dispose() - vi.restoreAllMocks() - } - }) - it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => { const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 }) try { @@ -230,3 +149,202 @@ describe('serialize oracle cell reuse', () => { } }) }) + +function allocatingRowDiff(stage: string, expected: string[][], actual: string[][]) { + for (let y = 0; y < Math.max(expected.length, actual.length); y++) { + const expectedRow = expected[y] + const actualRow = actual[y] + if ( + !expectedRow || + !actualRow || + expectedRow.length !== actualRow.length || + !expectedRow.every( + (cell, x) => cell === actualRow[x] || (cell === CLIPPED && actualRow[x]?.startsWith('▯')) + ) + ) { + return { stage, row: y, expected: expectedRow?.join('|'), actual: actualRow?.join('|') } + } + } + return null +} + +function expectComparisonParity( + expected: Buffer, + actual: Buffer, + cols: number, + expectedStart = 0, + expectedEnd = expected.length, + actualStart = 0, + actualEnd = actual.length +): ReturnType { + const frozen = allocatingRowDiff( + 'parity', + allocatingBufferRows(expected, expectedStart, expectedEnd, cols), + allocatingBufferRows(actual, actualStart, actualEnd, cols) + ) + expect( + compareBufferRows( + 'parity', + expected, + expectedStart, + expectedEnd, + actual, + actualStart, + actualEnd, + cols + ) + ).toEqual(frozen) + return frozen +} + +describe('serialize oracle streaming comparison', () => { + it('reuses one cell per buffer and stops before rows after the first difference', () => { + const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 }) + const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 }) + try { + writeTerminal(source, 'first\r\nsecond\r\nthird\r\nlast') + writeTerminal(replay, 'wrong\r\nsecond\r\nthird\r\nlast') + for (const buffer of [source.buffer.active, replay.buffer.active]) { + const scratch = buffer.getNullCell() + vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch) + const getLine = buffer.getLine.bind(buffer) + vi.spyOn(buffer, 'getLine').mockImplementation((y) => { + const line = getLine(y) + if (line) { + const getCell = line.getCell.bind(line) + vi.spyOn(line, 'getCell').mockImplementation((x, cell) => { + expect(cell).toBe(scratch) + return getCell(x, cell) + }) + } + return line + }) + } + const diff = compareBufferRows( + 'visible-grid', + source.buffer.active, + 0, + 4, + replay.buffer.active, + 0, + 4, + 8 + ) + expect(diff?.row).toBe(0) + for (const buffer of [source.buffer.active, replay.buffer.active]) { + expect(buffer.getNullCell).toHaveBeenCalledTimes(1) + expect(buffer.getLine).toHaveBeenCalledTimes(2) + expect(buffer.getLine).toHaveBeenCalledWith(0) + expect(buffer.getLine).toHaveBeenCalledWith(3) + } + writeTerminal(source, '\x1b[2J\x1b[Hchanged') + expect(diff?.expected).toContain('f·w1·f0:-1·b0:-1·0000000') + } finally { + source.dispose() + replay.dispose() + vi.restoreAllMocks() + } + }) + + it.each([false, true])( + 'preserves first-row diagnostics through buffer changes (ConPTY=%s)', + (conpty) => { + const source = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) + const replay = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) + try { + for (const [index, data] of [ + 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n', + '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n', + '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n', + 'scroll1\r\nscroll2\r\nscroll3\r\n', + '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m', + '\x1b[?1049l\x1b[2J\x1b[Hclear' + ].entries()) { + writeTerminal(source, data) + writeTerminal(replay, data) + for (const [expected, actual] of [ + [source.buffer.active, replay.buffer.active], + [source.buffer.normal, replay.buffer.normal], + [source.buffer.alternate, replay.buffer.alternate] + ]) { + expect(expectComparisonParity(expected!, actual!, source.cols)).toBeNull() + expectComparisonParity(expected!, actual!, source.cols + 2, -1, expected!.length + 1) + } + const corruption = `\x1b[H\x1b[0mFAULT${index}` + writeTerminal(replay, corruption) + expect( + expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols) + ).not.toBeNull() + writeTerminal(source, corruption) + source.resize(source.cols === 14 ? 9 : 14, 4) + replay.resize(source.cols, 4) + expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols) + } + } finally { + source.dispose() + replay.dispose() + } + } + ) + + it('detects every text-flag loss and compares all combinations against the allocating oracle', () => { + const source = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) + const replay = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) + try { + const sgr = [1, 2, 3, 4, 7, 8, 9] + const writeFlags = (terminal: Terminal, mask: number): void => { + const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0) + writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`) + } + for (let mask = 0; mask < 128; mask++) { + writeFlags(source, mask) + writeFlags(replay, mask) + expect(expectComparisonParity(source.buffer.active, replay.buffer.active, 4)).toBeNull() + for (let bit = 0; bit < sgr.length; bit++) { + if ((mask & (1 << bit)) !== 0) { + writeFlags(replay, mask & ~(1 << bit)) + expect( + expectComparisonParity(source.buffer.active, replay.buffer.active, 4) + ).not.toBeNull() + } + } + } + } finally { + source.dispose() + replay.dispose() + } + }) + + it.each([ + ['abc界', 'abc ', 4, false], + ['abc界', 'abc\x1b[48;2;1;2;3m ', 4, false], + ['abc ', 'abc界', 4, true], + ['é', 'è', 8, true], + ['\x1b[32mA', '\x1b[38;5;2mA', 8, false], + ['\x1b[42m ', '\x1b[48;5;2m ', 8, false], + ['\x1b[7;31m ', '\x1b[7;32m ', 8, true], + ['\x1b[4m\x1b[2J', '\x1b[2J', 8, false], + ['\x1b[4m ', ' ', 8, true], + ['\x1b[4m \x1b[0mB', ' B', 8, true], + ['text\r\n', 'text', 8, false], + ['text\r\n\x1b[48;2;1;2;3m\x1b[2K', 'text', 8, true] + ] as const)( + 'preserves blank, clipped and color policy for %j / %j', + (expected, actual, cols, differs) => { + const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 }) + const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 }) + try { + writeTerminal(source, expected) + writeTerminal(replay, actual) + expect( + Boolean(expectComparisonParity(source.buffer.active, replay.buffer.active, cols)) + ).toBe(differs) + expectComparisonParity(source.buffer.active, replay.buffer.active, cols, -1, 6, -1, 5) + expectComparisonParity(source.buffer.active, replay.buffer.active, cols, 1, 5, 0, 4) + } finally { + source.dispose() + replay.dispose() + } + } + ) +}) diff --git a/src/main/daemon/serialize-grid-cell-descriptors.ts b/src/main/daemon/serialize-grid-cell-descriptors.ts index 8b6c331f5fc..9adbbc6c694 100644 --- a/src/main/daemon/serialize-grid-cell-descriptors.ts +++ b/src/main/daemon/serialize-grid-cell-descriptors.ts @@ -7,6 +7,7 @@ export type GridDiff = { stage: string; row?: number; expected: unknown; actual: type BufferLine = NonNullable> type Buffer = Terminal['buffer']['active'] +type Cell = ReturnType const COLOR_MODE_P16 = 16777216 const COLOR_MODE_P256 = 33554432 @@ -64,33 +65,158 @@ function cellsMatch(expected: string, actual: string): boolean { return expected === actual || (expected === CLIPPED && actual.startsWith('▯')) } -function rowsMatch(expected: string[], actual: string[]): boolean { - return expected.length === actual.length && expected.every((e, i) => cellsMatch(e, actual[i]!)) +function sameColor( + expectedMode: number, + expectedColor: number, + actualMode: number, + actualColor: number +): boolean { + return ( + expectedColor === actualColor && + canonicalColorMode(expectedMode, expectedColor) === canonicalColorMode(actualMode, actualColor) + ) } -export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] { - const rows: string[][] = [] - const reusableCell = buffer.getNullCell() - for (let y = start; y < end; y++) { - rows.push(rowCells(buffer.getLine(y), cols, reusableCell)) +// Equal public fields imply identical descriptors; differing fields still use the frozen policy. +function sameCellFields(expected: Cell, actual: Cell): boolean { + if ( + expected.getWidth() !== actual.getWidth() || + !sameColor( + expected.getBgColorMode(), + expected.getBgColor(), + actual.getBgColorMode(), + actual.getBgColor() + ) + ) { + return false } - while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) { - rows.pop() + const expectedChars = expected.getChars() + const actualChars = actual.getChars() + const expectedBlank = expectedChars === '' || expectedChars === ' ' + const actualBlank = actualChars === '' || actualChars === ' ' + if (expectedBlank || actualBlank) { + return ( + expectedBlank && + actualBlank && + (expectedChars === ' ' && expected.isUnderline() !== 0) === + (actualChars === ' ' && actual.isUnderline() !== 0) && + (expectedChars === ' ' && expected.isStrikethrough() !== 0) === + (actualChars === ' ' && actual.isStrikethrough() !== 0) && + (expectedChars === ' ' && expected.isOverline() !== 0) === + (actualChars === ' ' && actual.isOverline() !== 0) && + (expected.isInverse() !== 0) === (actual.isInverse() !== 0) && + (expected.isInverse() === 0 || + sameColor( + expected.getFgColorMode(), + expected.getFgColor(), + actual.getFgColorMode(), + actual.getFgColor() + )) + ) } - return rows + return ( + expectedChars === actualChars && + sameColor( + expected.getFgColorMode(), + expected.getFgColor(), + actual.getFgColorMode(), + actual.getFgColor() + ) && + (expected.isBold() !== 0) === (actual.isBold() !== 0) && + (expected.isDim() !== 0) === (actual.isDim() !== 0) && + (expected.isItalic() !== 0) === (actual.isItalic() !== 0) && + (expected.isUnderline() !== 0) === (actual.isUnderline() !== 0) && + (expected.isInverse() !== 0) === (actual.isInverse() !== 0) && + (expected.isInvisible() !== 0) === (actual.isInvisible() !== 0) && + (expected.isStrikethrough() !== 0) === (actual.isStrikethrough() !== 0) + ) } -export function compareRowSets( +function trimmedEnd( + buffer: Buffer, + start: number, + end: number, + cols: number, + scratch: Cell +): number { + while (end > start) { + const line = buffer.getLine(end - 1) + let blank = true + for (let x = 0; x < cols; x++) { + if (cellDescriptor(line, x, cols, scratch) !== DEFAULT_BLANK) { + blank = false + break + } + } + if (!blank) { + break + } + end-- + } + return end +} + +function lineCellsMatch( + expected: BufferLine | undefined, + actual: BufferLine | undefined, + cols: number, + expectedScratch: Cell, + actualScratch: Cell +): boolean { + for (let x = 0; x < cols; x++) { + const expectedCell = + expected && x < expected.length ? expected.getCell(x, expectedScratch) : null + const actualCell = actual && x < actual.length ? actual.getCell(x, actualScratch) : null + const clipped = + x === cols - 1 && + ((expected && expected.length > cols && (expectedCell?.getWidth() ?? 0) > 1) || + (actual && actual.length > cols && (actualCell?.getWidth() ?? 0) > 1)) + if (expectedCell && actualCell && !clipped && sameCellFields(expectedCell, actualCell)) { + continue + } + if ( + !cellsMatch( + cellDescriptor(expected, x, cols, expectedScratch), + cellDescriptor(actual, x, cols, actualScratch) + ) + ) { + return false + } + } + return true +} + +/** Compares in place and formats only the first differing row, preserving diagnostic bytes. */ +export function compareBufferRows( stage: string, - expected: string[][], - actual: string[][] + expected: Buffer, + expectedStart: number, + expectedEnd: number, + actual: Buffer, + actualStart: number, + actualEnd: number, + cols: number ): GridDiff | null { - const length = Math.max(expected.length, actual.length) - for (let y = 0; y < length; y++) { - const e = expected[y] - const a = actual[y] - if (!e || !a || !rowsMatch(e, a)) { - return { stage, row: y, expected: e?.join('|'), actual: a?.join('|') } + const expectedScratch = expected.getNullCell() + const actualScratch = actual.getNullCell() + const expectedLength = + trimmedEnd(expected, expectedStart, expectedEnd, cols, expectedScratch) - expectedStart + const actualLength = trimmedEnd(actual, actualStart, actualEnd, cols, actualScratch) - actualStart + for (let y = 0; y < Math.max(expectedLength, actualLength); y++) { + const expectedLine = expected.getLine(expectedStart + y) + const actualLine = actual.getLine(actualStart + y) + if ( + y >= expectedLength || + y >= actualLength || + !lineCellsMatch(expectedLine, actualLine, cols, expectedScratch, actualScratch) + ) { + return { + stage, + row: y, + expected: + y < expectedLength ? rowCells(expectedLine, cols, expectedScratch).join('|') : undefined, + actual: y < actualLength ? rowCells(actualLine, cols, actualScratch).join('|') : undefined + } } } return null diff --git a/src/main/daemon/serialize-grid-roundtrip.ts b/src/main/daemon/serialize-grid-roundtrip.ts index d22bee6e511..26176c9ff5a 100644 --- a/src/main/daemon/serialize-grid-roundtrip.ts +++ b/src/main/daemon/serialize-grid-roundtrip.ts @@ -18,12 +18,7 @@ import { variantTrailingBackgroundRows } from './serialize-grid-variant-scope' import type { GridDiff } from './serialize-grid-cell-descriptors' -import { - bufferRows, - cellDescriptor, - CLIPPED, - compareRowSets -} from './serialize-grid-cell-descriptors' +import { cellDescriptor, CLIPPED, compareBufferRows } from './serialize-grid-cell-descriptors' export type NamedSerializer = { name: string; create: () => SerializeAddon } @@ -156,15 +151,25 @@ async function compareReplay( (src.type === dst.type ? null : { stage: 'active-buffer', expected: src.type, actual: dst.type }) ?? - compareRowSets( + compareBufferRows( 'visible-grid', - bufferRows(src, src.baseY, src.baseY + rows, cols), - bufferRows(dst, dst.baseY, dst.baseY + rows, cols) + src, + src.baseY, + src.baseY + rows, + dst, + dst.baseY, + dst.baseY + rows, + cols ) ?? - compareRowSets( + compareBufferRows( 'normal-buffer', - bufferRows(source.buffer.normal, normalStart, source.buffer.normal.length, cols), - bufferRows(replay.buffer.normal, 0, replay.buffer.normal.length, cols) + source.buffer.normal, + normalStart, + source.buffer.normal.length, + replay.buffer.normal, + 0, + replay.buffer.normal.length, + cols ) ?? (src.cursorX === dst.cursorX && src.cursorY === dst.cursorY ? null diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index d39078692c6..9a28f0a243a 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -253,7 +253,13 @@ describe('serialize round trip over captured PTY transcripts', () => { 'jitter/true/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63' ]) } - if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) { + // The pre-#22586 serializer has these same row-1 bold diffs on the untouched capture. + if (SEEDS === 2 && transcript.name === 'dsb-6-9-0-folder') { + expect(failureSignatures).toEqual([ + 'jitter/false/2/19:2050835176269320209c56be5ae9e525424635dfca511d44d3911d3a22ba9d27', + 'jitter/true/2/19:2050835176269320209c56be5ae9e525424635dfca511d44d3911d3a22ba9d27' + ]) + } else 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/shell-ready-bash-wrapper.test.ts b/src/main/daemon/shell-ready-bash-wrapper.test.ts index 0fbdce87029..059612f5a4b 100644 --- a/src/main/daemon/shell-ready-bash-wrapper.test.ts +++ b/src/main/daemon/shell-ready-bash-wrapper.test.ts @@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile' +import { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' +import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' +import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { OVERLAY_ONLY_FEATURES, STARTUP_COMMAND_FEATURES @@ -57,6 +60,45 @@ describePosix('daemon shell-ready bash wrapper', () => { vi.restoreAllMocks() }) + itWithBash.each([ + ['daemon', getDaemonBashShellReadyRcfileContent], + ['local', getBashShellReadyRcfileContent] + ] as const)('keeps this app CLI first after %s Bash profiles reset PATH', (_lane, content) => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + const env: Record = { + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin`, + ORCA_BACKGROUND_LAUNCH: '1' + } + const expectedLauncher = prependOrcaCliDirToChildPath(env, { + isPackaged: false, + userDataPath + }) + writeFileSync( + join(userDataPath, '.bash_profile'), + 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n' + ) + const rcfile = join(userDataPath, 'cli-path-rcfile') + writeFileSync(rcfile, content()) + const result = spawnSync('bash', ['-c', '. "$1"; command -v orca-dev', 'bash', rcfile], { + env, + encoding: 'utf8', + timeout: 5000 + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + expect(result.stdout.split('\x1b]133;C\x07').join('').trim()).toBe(expectedLauncher) + }) + // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() diff --git a/src/main/daemon/terminal-cursor-line-context.test.ts b/src/main/daemon/terminal-cursor-line-context.test.ts index 86066b95b8c..29afe00618b 100644 --- a/src/main/daemon/terminal-cursor-line-context.test.ts +++ b/src/main/daemon/terminal-cursor-line-context.test.ts @@ -9,7 +9,96 @@ function writeSync(terminal: Terminal, data: string): void { core.writeSync(data) } +type Cell = ReturnType + +function observeCellReads(terminal: Terminal) { + const allocated: Cell[] = [] + const targets: (Cell | undefined)[] = [] + const active = terminal.buffer.active + const source = { + rows: terminal.rows, + modes: terminal.modes, + buffer: { + active: { + baseY: active.baseY, + cursorX: active.cursorX, + cursorY: active.cursorY, + viewportY: active.viewportY, + getNullCell: () => { + const cell = active.getNullCell() + allocated.push(cell) + return cell + }, + getLine: (row: number) => { + const line = active.getLine(row) + if (!line) { + return undefined + } + return { + isWrapped: line.isWrapped, + length: line.length, + translateToString: line.translateToString.bind(line), + getCell: (column: number, reusableCell?: Cell) => { + targets.push(reusableCell) + return line.getCell(column, reusableCell) + } + } + } + } + } + } + return { source, allocated, targets } +} + describe('readTerminalCursorLineContext', () => { + it('reuses one cell across every scan without retaining it between reads', () => { + const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true }) + try { + writeSync(terminal, '\x1b[1m❯\x1b[22m 界e\u0301\x1b7\r\n\x1b[31mfooter\x1b8') + const rig = observeCellReads(terminal) + const first = readTerminalCursorLineContext(rig.source, terminal.rows) + expect(rig.allocated).toHaveLength(1) + expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2) + expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true) + rig.targets.length = 0 + expect(readTerminalCursorLineContext(rig.source, terminal.rows)).toEqual(first) + expect(rig.allocated).toHaveLength(2) + expect(rig.allocated[1]).not.toBe(rig.allocated[0]) + expect(rig.targets.every((cell) => cell === rig.allocated[1])).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('preserves full context for an adapter without reusable cells', () => { + const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true }) + try { + writeSync( + terminal, + '\x1b[1m❯\x1b[22m typed\x1b7\x1b[2m hint\x1b[22m\r\n\x1b[38;2;1;2;3m界e\u0301\x1b[0m\x1b8' + ) + const rig = observeCellReads(terminal) + const source = { + ...rig.source, + buffer: { active: { ...rig.source.buffer.active, getNullCell: undefined } } + } + const context = readTerminalCursorLineContext(source, terminal.rows) + expect(context).toEqual(readTerminalCursorLineContext(terminal, terminal.rows)) + expect(context?.typedRows).toEqual(['❯ typed']) + expect(context?.typedRowsBelow[0]).toBe('界e\u0301') + expect(context?.beforeCursor).toBe('❯ typed') + expect(context?.afterCursor).toBe('') + expect(context?.rawAfterCursor).toBe(' hint') + expect(context?.promptGlyphBoldRows).toEqual([true]) + expect(context?.rowsBelowCustomForeground?.[0]).toBe(true) + expect(rig.allocated).toEqual([]) + expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2) + expect(rig.targets.every((cell) => cell === undefined)).toBe(true) + } finally { + terminal.dispose() + } + }) + it.each([ { cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' }, { cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' } diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index d5669618c7a..93b4dee6e58 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,20 +1,14 @@ import type { Session } from './session' -import { - SessionNotFoundError, - type SessionInfo, - type TakePendingOutputResult, - type TerminalSnapshot -} from './types' +import { SessionNotFoundError } from './types' +import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' import type { CreateOrAttachResult } from './terminal-host-create-contract' import type { TerminalHostOptions } from './terminal-host-options' import { disposeTerminalHostSessions } from './terminal-host-disposal' import { getAliveTerminalHostSession } from './terminal-host-session-access' import { TerminalSessionTeardown } from './terminal-session-teardown' import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' -import { - createOrAttachClaimedAgentSession, - type InternalCreateOrAttachOptions -} from './terminal-host-agent-session-claim' +import { createOrAttachClaimedAgentSession } from './terminal-host-agent-session-claim' +import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' import { TerminalHostAgentSessionGenerations } from './terminal-host-agent-session-generations' import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd' import { TerminalHostTombstones } from './terminal-host-tombstones' @@ -24,10 +18,8 @@ import { TerminalAttachCanceledError } from './daemon-errors' import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' import { randomUUID } from 'node:crypto' import { pruneRetiredPtyIncarnations } from '../../shared/retired-pty-incarnations' -import { - inspectTerminalHostProcess, - type TerminalHostProcessInspection -} from './terminal-host-process-inspection' +import { inspectTerminalHostProcess } from './terminal-host-process-inspection' +import type { TerminalHostProcessInspection } from './terminal-host-process-inspection' import { confirmTerminalHostForegroundProcess, confirmTerminalHostShellForeground, @@ -320,6 +312,15 @@ export class TerminalHost { return listLiveTerminalHostSessions(this.sessions, this.agentSessionOwners) } + hasLiveSessions(): boolean { + let hasLive = false + // Read every Session, even after a live one, to preserve the inventory's error order. + for (const session of this.sessions.values()) { + hasLive = session.isAlive || hasLive + } + return hasLive + } + dispose(): Promise { this.creationFenced = true if (this.disposePromise) { diff --git a/src/main/git/command-runner/exec-file-capture.ts b/src/main/git/command-runner/exec-file-capture.ts index b94d2f59509..267fc7e20a5 100644 --- a/src/main/git/command-runner/exec-file-capture.ts +++ b/src/main/git/command-runner/exec-file-capture.ts @@ -2,6 +2,7 @@ import { execFile, type ChildProcess, type ExecFileOptions } from 'node:child_pr import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' import { endSubprocessStdin } from '../../../shared/subprocess-stdin-write' import { runProcess } from '../../../shared/child-process/run-process' +import { resolveSelectedLocalCommand } from '../../ipc/command-path-resolver' import type { WslProcessGroupTermination } from '../wsl-process-group-termination' import { createAbortError } from './abort-error' import { killSpawnedCommandTree } from './spawned-command-tree-kill' @@ -30,7 +31,10 @@ export async function execFileCaptureToTermination( // Spawn cost is reported by spawnProcess's observer, which runProcess goes // through; recording it again here would double-count every capture. const pending = runProcess({ - program: command, + program: resolveSelectedLocalCommand(command, { + env: options.env, + cwd: typeof options.cwd === 'string' ? options.cwd : undefined + }), args, cwd: typeof options.cwd === 'string' ? options.cwd : undefined, env: options.env, diff --git a/src/main/git/command-runner/git-admission-storm-measurement.test.ts b/src/main/git/command-runner/git-admission-storm-measurement.test.ts index 2fb3e896b20..f7ebc3036b2 100644 --- a/src/main/git/command-runner/git-admission-storm-measurement.test.ts +++ b/src/main/git/command-runner/git-admission-storm-measurement.test.ts @@ -1,250 +1,183 @@ -/** - * POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path. - * Timing distributions are reported for field comparison; CI assertions stay structural. - */ -import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { existsSync, watch } from 'node:fs' +import { chmod, mkdir, readdir, writeFile } from 'node:fs/promises' import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' +import { runProcess } from '../../../shared/child-process/run-process' import { gitExecFileAsync } from './git-exec-file' import { - GIT_ADMISSION_AGING_MS, + GENERAL_CAP, + GENERAL_HEADROOM, GitAdmissionScheduler, - MAX_GIT_CHILDREN, _gitAdmissionSnapshotForTests, _resetGitAdmissionForTests, type GitAdmissionEvent } from './git-subprocess-admission' +import { + assertAdmissionLedger, + createStormRoot, + formatMeasurementTable, + measureStorm, + type InteractiveQueueSnapshot +} from './git-admission-storm-test-fixture' -type StormMeasurement = { - mode: 'disabled' | 'enabled' - maxConcurrentChildren: number - eventLoopMaxDriftMs: number - eventLoopP99DriftMs: number - interactiveP50Ms: number - interactiveP95Ms: number - totalWallMs: number - admissionEvents: GitAdmissionEvent[] - interactiveQueueSnapshots: InteractiveQueueSnapshot[] +function waitForLiveChild(stateDir: string, id: string, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const watcher = watch(stateDir) + const finish = (error?: Error): void => { + clearTimeout(deadline) + watcher.close() + signal.removeEventListener('abort', aborted) + if (error) { + reject(error) + } else { + resolve() + } + } + const aborted = (): void => finish(new Error(`Aborted waiting for ${id}`)) + const deadline = setTimeout(() => finish(new Error(`Child ${id} did not start`)), 5_000) + const check = (): void => { + if (existsSync(path.join(stateDir, `${id}.live`))) { + finish() + } + } + watcher.on('change', check) + watcher.once('error', finish) + signal.addEventListener('abort', aborted, { once: true }) + if (signal.aborted) { + aborted() + } else { + check() + } + }) } -type InteractiveQueueSnapshot = { - commandLabel: string - backgroundWaiterIds: number[] -} - -const tempRoots: string[] = [] -const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED - -afterEach(async () => { - if (originalAdmissionDisabled === undefined) { - delete process.env.ORCA_GIT_ADMISSION_DISABLED - } else { - process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled - } - _resetGitAdmissionForTests() - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -function percentile(values: readonly number[], percentileValue: number): number { - if (values.length === 0) { - return 0 - } - const sorted = [...values].sort((a, b) => a - b) - return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)] -} - -async function liveChildCount(stateDir: string): Promise { - return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length -} - -async function createStubGit(root: string): Promise { +async function verifySpawnContention(): Promise { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + const admissionEvents: GitAdmissionEvent[] = [] + _resetGitAdmissionForTests( + new GitAdmissionScheduler({ + now: () => 0, + onAdmissionEvent: (event) => admissionEvents.push(event) + }) + ) + const root = await createStormRoot('contention') + const stateDir = path.join(root, 'state') + const gateDir = path.join(root, 'gates') const binDir = path.join(root, 'bin') - await mkdir(binDir) - const stubPath = path.join(binDir, 'git') + await Promise.all([stateDir, gateDir, binDir].map((directory) => mkdir(directory))) + const backgroundIds = Array.from({ length: GENERAL_CAP + 4 }, (_, index) => `background-${index}`) + const headroomIds = Array.from({ length: GENERAL_HEADROOM }, (_, index) => `interactive-${index}`) + const queuedIds = ['interactive-queued-0', 'interactive-queued-1'] + const ids = [...backgroundIds, ...headroomIds, ...queuedIds] + const gates = await runProcess({ + program: 'mkfifo', + args: ids.map((id) => path.join(gateDir, id)) + }) + expect(gates.code, gates.stderr).toBe(0) + const stub = path.join(binDir, 'git') await writeFile( - stubPath, + stub, `#!/bin/sh set -eu live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live" : > "$live" trap 'rm -f "$live"' EXIT HUP INT TERM -sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")" +IFS= read -r release < "$ORCA_STUB_GATE_DIR/$ORCA_STUB_ID" printf 'stub:%s\\n' "$*" ` ) - await chmod(stubPath, 0o755) - return binDir -} - -function setAdmissionMode(mode: StormMeasurement['mode']): void { - if (mode === 'disabled') { - process.env.ORCA_GIT_ADMISSION_DISABLED = '1' - } else { - delete process.env.ORCA_GIT_ADMISSION_DISABLED - } -} - -async function measureStorm(mode: StormMeasurement['mode']): Promise { - setAdmissionMode(mode) - const admissionEvents: GitAdmissionEvent[] = [] - const admissionClockStartedAt = performance.now() - _resetGitAdmissionForTests( - new GitAdmissionScheduler({ - now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1), - onAdmissionEvent: (event) => admissionEvents.push(event) - }) - ) - const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${mode}-`)) - tempRoots.push(root) - const stateDir = path.join(root, 'state') - await mkdir(stateDir) - const binDir = await createStubGit(root) - const repoDirs = await Promise.all( - Array.from({ length: 6 }, async (_, index) => { - const repoDir = path.join(root, `repo-${index}`) - await mkdir(repoDir) - return repoDir - }) - ) - const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` } - const startedAt = performance.now() - const drifts: number[] = [] - let nextJankSample = performance.now() + 50 - const jankTimer = setInterval(() => { - const now = performance.now() - drifts.push(Math.max(0, now - nextJankSample)) - nextJankSample = now + 50 - }, 50) - let maxConcurrentChildren = 0 - const censusTimer = setInterval(() => { - void liveChildCount(stateDir).then((count) => { - maxConcurrentChildren = Math.max(maxConcurrentChildren, count) - }) - }, 5) - - const background = Array.from({ length: 60 }, (_, index) => - gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], { - cwd: repoDirs[index % repoDirs.length], - env: { - ...baseEnv, - ORCA_STUB_ID: `background-${index}`, - ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200', - ORCA_STUB_STATE_DIR: stateDir - }, - admissionTier: 'background' - }) - ) + await chmod(stub, 0o755) + const controller = new AbortController() + const commands = new Map>() const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = [] - const interactiveLatencies = await Promise.all( - Array.from( - { length: 10 }, - (_, index) => - new Promise((resolve, reject) => { - setTimeout( - () => { - void (async () => { - const concurrentAtInjection = await liveChildCount(stateDir) - const commandStartedAt = performance.now() - const commandLabel = `interactive-${index}` - interactiveQueueSnapshots.push({ - commandLabel, - backgroundWaiterIds: _gitAdmissionSnapshotForTests() - .queuedWaiters.filter((waiter) => waiter.tier === 'background') - .map((waiter) => waiter.id) - }) - await gitExecFileAsync(['rev-parse', commandLabel], { - cwd: repoDirs[index % repoDirs.length], - env: { - ...baseEnv, - ORCA_STUB_ID: `interactive-${index}`, - ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)), - ORCA_STUB_STATE_DIR: stateDir - }, - admissionTier: 'interactive' - }) - resolve(performance.now() - commandStartedAt) - })().catch(reject) - }, - 50 * (index + 1) - ) - }) - ) - ) - await Promise.all(background) - clearInterval(censusTimer) - clearInterval(jankTimer) - maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir)) - const totalWallMs = performance.now() - startedAt - return { - mode, - maxConcurrentChildren, - eventLoopMaxDriftMs: Math.max(0, ...drifts), - eventLoopP99DriftMs: percentile(drifts, 0.99), - interactiveP50Ms: percentile(interactiveLatencies, 0.5), - interactiveP95Ms: percentile(interactiveLatencies, 0.95), - totalWallMs, - admissionEvents, - interactiveQueueSnapshots - } -} - -function formatMeasurementTable(rows: readonly StormMeasurement[]): string { - const rounded = rows.map((row) => ({ - mode: row.mode, - maxConcurrentChildren: row.maxConcurrentChildren, - eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1), - eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1), - interactiveP50Ms: row.interactiveP50Ms.toFixed(1), - interactiveP95Ms: row.interactiveP95Ms.toFixed(1), - totalWallMs: row.totalWallMs.toFixed(1) - })) - return JSON.stringify(rounded) -} - -function assertAdmissionLedger(measurement: StormMeasurement): void { - const activeWaiters = new Set() - const grantSequenceByWaiter = new Map() - const grantByLabel = new Map() - - measurement.admissionEvents.forEach((event, index) => { - expect(event.sequence).toBe(index) - if (event.phase === 'grant') { - activeWaiters.add(event.waiterId) - grantSequenceByWaiter.set(event.waiterId, event.sequence) - const label = event.args.find((arg) => arg.startsWith('interactive-')) - if (label) { - grantByLabel.set(label, event) + const start = (id: string, tier: 'background' | 'interactive'): void => { + if (tier === 'interactive') { + interactiveQueueSnapshots.push({ + commandLabel: id, + backgroundWaiterIds: _gitAdmissionSnapshotForTests() + .queuedWaiters.filter((waiter) => waiter.tier === 'background') + .map((waiter) => waiter.id) + }) + } + const command = gitExecFileAsync([tier === 'background' ? 'status' : 'rev-parse', id], { + cwd: root, + admissionTier: tier, + signal: controller.signal, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + ORCA_STUB_ID: id, + ORCA_STUB_STATE_DIR: stateDir, + ORCA_STUB_GATE_DIR: gateDir } - } else { - expect(activeWaiters.delete(event.waiterId)).toBe(true) + }) + void command.catch(() => {}) + commands.set(id, command) + } + const release = async (id: string): Promise => { + await waitForLiveChild(stateDir, id, controller.signal) + await writeFile(path.join(gateDir, id), 'release\n') + await expect(commands.get(id)).resolves.toEqual({ + stdout: `stub:${id.startsWith('background-') ? 'status' : 'rev-parse'} ${id}\n`, + stderr: '' + }) + } + try { + backgroundIds.forEach((id) => start(id, 'background')) + expect(_gitAdmissionSnapshotForTests().queued).toBe(4) + headroomIds.forEach((id) => start(id, 'interactive')) + queuedIds.forEach((id) => start(id, 'interactive')) + expect(_gitAdmissionSnapshotForTests()).toMatchObject({ + queued: 6, + budgets: { general: { baseUsed: GENERAL_CAP, headroomUsed: GENERAL_HEADROOM } } + }) + await Promise.all( + [...backgroundIds.slice(0, GENERAL_CAP), ...headroomIds].map((id) => + waitForLiveChild(stateDir, id, controller.signal) + ) + ) + expect(await readdir(stateDir)).toHaveLength(GENERAL_CAP + GENERAL_HEADROOM) + for (const [index, id] of queuedIds.entries()) { + await release(backgroundIds[index]) + await waitForLiveChild(stateDir, id, controller.signal) } - expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN) - for (const budget of event.budgets) { - expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity) - expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity) + expect(_gitAdmissionSnapshotForTests().queuedWaiters.map((waiter) => waiter.tier)).toEqual([ + 'background', + 'background', + 'background', + 'background' + ]) + for (const id of [...headroomIds, ...queuedIds, ...backgroundIds.slice(queuedIds.length)]) { + await release(id) } - }) - expect(activeWaiters.size).toBe(0) - - for (const snapshot of measurement.interactiveQueueSnapshots) { - const interactiveGrant = grantByLabel.get(snapshot.commandLabel) - expect(interactiveGrant).toBeDefined() - const preceded = snapshot.backgroundWaiterIds.filter( - (waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity) - ).length - expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9)) + expect(admissionEvents.filter((event) => event.phase === 'grant')).toHaveLength(ids.length) + expect(admissionEvents.filter((event) => event.phase === 'release')).toHaveLength(ids.length) + assertAdmissionLedger({ admissionEvents, interactiveQueueSnapshots }) + expect(_gitAdmissionSnapshotForTests()).toMatchObject({ + queued: 0, + budgets: { general: { baseUsed: 0, headroomUsed: 0 } } + }) + expect(await readdir(stateDir)).toEqual([]) + } finally { + controller.abort() + await Promise.allSettled(commands.values()) } } describe.skipIf(process.platform === 'win32')('git admission storm measurement', () => { - it('reports bounded-concurrency before and after measurements', async () => { - const disabled = await measureStorm('disabled') - const enabled = await measureStorm('enabled') - console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`) - assertAdmissionLedger(enabled) - }) + it( + 'bounds real children and drains interactive work before the background backlog', + verifySpawnContention + ) - // Output parity lives in git-admission-output-parity.test.ts: it needs real git, - // not the PATH stub, so it runs on win32 too and cannot share this file's gate. + it.runIf(process.env.ORCA_GIT_ADMISSION_STORM_MEASUREMENT === '1')( + 'reports bounded-concurrency before and after measurements', + async () => { + const disabled = await measureStorm('disabled') + const enabled = await measureStorm('enabled') + console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`) + assertAdmissionLedger(enabled) + } + ) + // Real-git output parity remains in git-admission-output-parity.test.ts, including Windows. }) diff --git a/src/main/git/command-runner/git-admission-storm-test-fixture.ts b/src/main/git/command-runner/git-admission-storm-test-fixture.ts new file mode 100644 index 00000000000..4ee1219f027 --- /dev/null +++ b/src/main/git/command-runner/git-admission-storm-test-fixture.ts @@ -0,0 +1,245 @@ +/** + * POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path. + * Timing distributions are reported for field comparison; CI assertions stay structural. + */ +import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, expect } from 'vitest' +import { gitExecFileAsync } from './git-exec-file' +import { + GIT_ADMISSION_AGING_MS, + GitAdmissionScheduler, + MAX_GIT_CHILDREN, + _gitAdmissionSnapshotForTests, + _resetGitAdmissionForTests, + type GitAdmissionEvent +} from './git-subprocess-admission' + +type StormMeasurement = { + mode: 'disabled' | 'enabled' + maxConcurrentChildren: number + eventLoopMaxDriftMs: number + eventLoopP99DriftMs: number + interactiveP50Ms: number + interactiveP95Ms: number + totalWallMs: number + admissionEvents: GitAdmissionEvent[] + interactiveQueueSnapshots: InteractiveQueueSnapshot[] +} + +export type InteractiveQueueSnapshot = { + commandLabel: string + backgroundWaiterIds: number[] +} + +const tempRoots: string[] = [] +const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED + +afterEach(async () => { + if (originalAdmissionDisabled === undefined) { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + } else { + process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled + } + _resetGitAdmissionForTests() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +export async function createStormRoot(label: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${label}-`)) + tempRoots.push(root) + return root +} + +function percentile(values: readonly number[], percentileValue: number): number { + if (values.length === 0) { + return 0 + } + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)] +} + +async function liveChildCount(stateDir: string): Promise { + return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length +} + +async function createStubGit(root: string): Promise { + const binDir = path.join(root, 'bin') + await mkdir(binDir) + const stubPath = path.join(binDir, 'git') + await writeFile( + stubPath, + `#!/bin/sh +set -eu +live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live" +: > "$live" +trap 'rm -f "$live"' EXIT HUP INT TERM +sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")" +printf 'stub:%s\\n' "$*" +` + ) + await chmod(stubPath, 0o755) + return binDir +} + +function setAdmissionMode(mode: StormMeasurement['mode']): void { + if (mode === 'disabled') { + process.env.ORCA_GIT_ADMISSION_DISABLED = '1' + } else { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + } +} + +export async function measureStorm(mode: StormMeasurement['mode']): Promise { + setAdmissionMode(mode) + const admissionEvents: GitAdmissionEvent[] = [] + const admissionClockStartedAt = performance.now() + _resetGitAdmissionForTests( + new GitAdmissionScheduler({ + now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1), + onAdmissionEvent: (event) => admissionEvents.push(event) + }) + ) + const root = await createStormRoot(mode) + const stateDir = path.join(root, 'state') + await mkdir(stateDir) + const binDir = await createStubGit(root) + const repoDirs = await Promise.all( + Array.from({ length: 6 }, async (_, index) => { + const repoDir = path.join(root, `repo-${index}`) + await mkdir(repoDir) + return repoDir + }) + ) + const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` } + const startedAt = performance.now() + const drifts: number[] = [] + let nextJankSample = performance.now() + 50 + const jankTimer = setInterval(() => { + const now = performance.now() + drifts.push(Math.max(0, now - nextJankSample)) + nextJankSample = now + 50 + }, 50) + let maxConcurrentChildren = 0 + const censusTimer = setInterval(() => { + void liveChildCount(stateDir).then((count) => { + maxConcurrentChildren = Math.max(maxConcurrentChildren, count) + }) + }, 5) + + const background = Array.from({ length: 60 }, (_, index) => + gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], { + cwd: repoDirs[index % repoDirs.length], + env: { + ...baseEnv, + ORCA_STUB_ID: `background-${index}`, + ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200', + ORCA_STUB_STATE_DIR: stateDir + }, + admissionTier: 'background' + }) + ) + const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = [] + const interactiveLatencies = await Promise.all( + Array.from( + { length: 10 }, + (_, index) => + new Promise((resolve, reject) => { + setTimeout( + () => { + void (async () => { + const concurrentAtInjection = await liveChildCount(stateDir) + const commandStartedAt = performance.now() + const commandLabel = `interactive-${index}` + interactiveQueueSnapshots.push({ + commandLabel, + backgroundWaiterIds: _gitAdmissionSnapshotForTests() + .queuedWaiters.filter((waiter) => waiter.tier === 'background') + .map((waiter) => waiter.id) + }) + await gitExecFileAsync(['rev-parse', commandLabel], { + cwd: repoDirs[index % repoDirs.length], + env: { + ...baseEnv, + ORCA_STUB_ID: `interactive-${index}`, + ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)), + ORCA_STUB_STATE_DIR: stateDir + }, + admissionTier: 'interactive' + }) + resolve(performance.now() - commandStartedAt) + })().catch(reject) + }, + 50 * (index + 1) + ) + }) + ) + ) + await Promise.all(background) + clearInterval(censusTimer) + clearInterval(jankTimer) + maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir)) + const totalWallMs = performance.now() - startedAt + return { + mode, + maxConcurrentChildren, + eventLoopMaxDriftMs: Math.max(0, ...drifts), + eventLoopP99DriftMs: percentile(drifts, 0.99), + interactiveP50Ms: percentile(interactiveLatencies, 0.5), + interactiveP95Ms: percentile(interactiveLatencies, 0.95), + totalWallMs, + admissionEvents, + interactiveQueueSnapshots + } +} + +export function formatMeasurementTable(rows: readonly StormMeasurement[]): string { + const rounded = rows.map((row) => ({ + mode: row.mode, + maxConcurrentChildren: row.maxConcurrentChildren, + eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1), + eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1), + interactiveP50Ms: row.interactiveP50Ms.toFixed(1), + interactiveP95Ms: row.interactiveP95Ms.toFixed(1), + totalWallMs: row.totalWallMs.toFixed(1) + })) + return JSON.stringify(rounded) +} + +export function assertAdmissionLedger( + measurement: Pick +): void { + const activeWaiters = new Set() + const grantSequenceByWaiter = new Map() + const grantByLabel = new Map() + + measurement.admissionEvents.forEach((event, index) => { + expect(event.sequence).toBe(index) + if (event.phase === 'grant') { + activeWaiters.add(event.waiterId) + grantSequenceByWaiter.set(event.waiterId, event.sequence) + const label = event.args.find((arg) => arg.startsWith('interactive-')) + if (label) { + grantByLabel.set(label, event) + } + } else { + expect(activeWaiters.delete(event.waiterId)).toBe(true) + } + expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN) + for (const budget of event.budgets) { + expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity) + expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity) + } + }) + expect(activeWaiters.size).toBe(0) + + for (const snapshot of measurement.interactiveQueueSnapshots) { + const interactiveGrant = grantByLabel.get(snapshot.commandLabel) + expect(interactiveGrant).toBeDefined() + const preceded = snapshot.backgroundWaiterIds.filter( + (waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity) + ).length + expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9)) + } +} diff --git a/src/main/github/client.ts b/src/main/github/client.ts index fa331749c8b..ed72bc12232 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -23,7 +23,11 @@ export { type PullRequestPushTarget } from './client/lookup/pull-request-push-target' export { getRepoSlug, getRepoUpstream } from './client/fetch/repo-slug-upstream' -export { getWorkItem, getWorkItemByOwnerRepo } from './client/fetch/get-work-item' +export { + getWorkItem, + getWorkItemByOwnerRepo, + getWorkItemWithRepository +} from './client/fetch/get-work-item' export { listWorkItems } from './client/list/list-work-items' export { mergePR } from './client/merge/merge-pr' export { removePRReviewers, requestPRReviewers } from './client/update/pr-reviewers' diff --git a/src/main/github/client/fetch/get-work-item.ts b/src/main/github/client/fetch/get-work-item.ts index 3178a4c921a..5224061dfa3 100644 --- a/src/main/github/client/fetch/get-work-item.ts +++ b/src/main/github/client/fetch/get-work-item.ts @@ -21,6 +21,30 @@ export async function getWorkItem( localGitOptions: LocalGitExecOptions = {}, preference?: IssueSourcePreference ): Promise { + const { item } = await getWorkItemWithRepository( + repoPath, + number, + type, + connectionId, + localGitOptions, + preference + ) + return item +} + +export type WorkItemRepositoryLookup = { + item: MainWorkItem | null + repository: GitHubApiRepository | null +} + +export async function getWorkItemWithRepository( + repoPath: string, + number: number, + type?: 'issue' | 'pr', + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference +): Promise { await acquire() try { // Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number @@ -35,18 +59,24 @@ export async function getWorkItem( // Why: explicit origin with no origin identity must not bare-lookup ambient gh // (same fail-closed rule as origin-pinned PR candidate resolution). if (!source && preference === 'origin') { - return null + return { item: null, repository: null } + } + return { + item: await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions), + repository: source } - return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions) } if (type === 'pr') { - return await fetchPullRequestWorkItemFromCandidates( - repoPath, - number, - connectionId, - localGitOptions, - preference - ) + return { + item: await fetchPullRequestWorkItemFromCandidates( + repoPath, + number, + connectionId, + localGitOptions, + preference + ), + repository: null + } } try { @@ -65,7 +95,7 @@ export async function getWorkItem( localGitOptions ) if (issue) { - return issue + return { item: issue, repository: source } } } } catch (err) { @@ -75,15 +105,18 @@ export async function getWorkItem( throw err } } - return await fetchPullRequestWorkItemFromCandidates( - repoPath, - number, - connectionId, - localGitOptions, - preference - ) + return { + item: await fetchPullRequestWorkItemFromCandidates( + repoPath, + number, + connectionId, + localGitOptions, + preference + ), + repository: null + } } catch { - return null + return { item: null, repository: null } } finally { release() } diff --git a/src/main/github/github-api-repository-validation.test.ts b/src/main/github/github-api-repository-validation.test.ts index 81e395a8bdb..7f8eec06602 100644 --- a/src/main/github/github-api-repository-validation.test.ts +++ b/src/main/github/github-api-repository-validation.test.ts @@ -22,4 +22,25 @@ describe('isValidGitHubApiRepository', () => { expect(isValidGitHubApiRepository({ owner: 'acme', repo: '..' })).toBe(false) expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'a/b' })).toBe(false) }) + + it.each([42, false, null, {}])('rejects a non-string repository host: %j', (host) => { + expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(false) + }) + + it.each([undefined, 'github.com', 'github.enterprise.test:8443'])( + 'accepts supported legacy or explicit repository host types: %j', + (host) => { + expect(isValidGitHubApiRepository({ owner: 'acme', repo: 'orca', host })).toBe(true) + } + ) + + it.each([ + null, + 'acme/orca', + { owner: 42, repo: 'orca' }, + { owner: 'acme', repo: 42 }, + { owner: 'acme' } + ])('rejects malformed runtime repository identities: %j', (repository) => { + expect(isValidGitHubApiRepository(repository)).toBe(false) + }) }) diff --git a/src/main/github/github-api-repository-validation.ts b/src/main/github/github-api-repository-validation.ts index b687a0ebdee..4031e64db13 100644 --- a/src/main/github/github-api-repository-validation.ts +++ b/src/main/github/github-api-repository-validation.ts @@ -11,8 +11,21 @@ export type GitHubApiRepositoryResolution = const OWNER_SLUG_RE = GITHUB_OWNER_SLUG_RE const REPOSITORY_SLUG_RE = /^[A-Za-z0-9._-]+$/ -export function isValidGitHubApiRepository(repository: GitHubOwnerRepo): boolean { +export function isValidGitHubApiRepository(repository: unknown): repository is GitHubOwnerRepo { + if ( + !repository || + typeof repository !== 'object' || + !('owner' in repository) || + !('repo' in repository) + ) { + return false + } return ( + typeof repository.owner === 'string' && + typeof repository.repo === 'string' && + (!('host' in repository) || + repository.host === undefined || + typeof repository.host === 'string') && OWNER_SLUG_RE.test(repository.owner) && REPOSITORY_SLUG_RE.test(repository.repo) && repository.repo !== '.' && diff --git a/src/main/github/issue-comment.ts b/src/main/github/issue-comment.ts index ac3f6a205de..478ac1ce68c 100644 --- a/src/main/github/issue-comment.ts +++ b/src/main/github/issue-comment.ts @@ -1,32 +1,34 @@ import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types' +import type { IssueSourcePreference } from '../../shared/repo-types' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' -import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository' +import { + resolveGitHubRepoExecution, + resolveIssueGitHubApiRepositorySource +} from './github-api-repository' import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils' -/** - * Add a comment to an existing GitHub issue. - * - * Why this path doesn't take a preference (mirrors `getIssue` / `updateIssue`): - * a comment is posted against an issue number already bound to a worktree or - * surfaced from a prior read. Routing through the live per-repo preference - * would let a user read upstream#N, toggle the selector to origin, and have - * their reply silently post on origin#N — a different issue entirely. That - * is the same silent-source-switch class of wrongness #1186 / the parent - * design doc guard against. List and create paths honor preference; - * mutations stay on the heuristic `getIssueOwnerRepo`. - */ +// An explicit target keeps replies bound to the conversation; otherwise use the selected source. export async function addIssueComment( repoPath: string, issueNumber: number, body: string, connectionId?: string | null, ownerRepoOverride?: OwnerRepo | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, ownerRepoOverride ?? - (() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)), + (async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source), connectionId, localGitOptions ) diff --git a/src/main/github/issue-field-options.ts b/src/main/github/issue-field-options.ts index a4d2aae32f4..77e2337ae8a 100644 --- a/src/main/github/issue-field-options.ts +++ b/src/main/github/issue-field-options.ts @@ -1,4 +1,4 @@ -import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' +import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types' import type { IssueSourcePreference } from '../../shared/repo-types' import type { LocalGitExecOptions } from './gh-utils' import { @@ -11,19 +11,21 @@ export async function listLabels( repoPath: string, preference?: IssueSourcePreference, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + repositoryOverride?: GitHubOwnerRepo | null ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - async () => - ( - await resolveIssueGitHubApiRepositorySource( - repoPath, - preference, - connectionId, - localGitOptions - ) - ).source, + repositoryOverride ?? + (async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source), connectionId, localGitOptions ) @@ -57,19 +59,21 @@ export async function listAssignableUsers( repoPath: string, preference?: IssueSourcePreference, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + repositoryOverride?: GitHubOwnerRepo | null ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - async () => - ( - await resolveIssueGitHubApiRepositorySource( - repoPath, - preference, - connectionId, - localGitOptions - ) - ).source, + repositoryOverride ?? + (async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source), connectionId, localGitOptions ) diff --git a/src/main/github/issue-mutation-source.test.ts b/src/main/github/issue-mutation-source.test.ts new file mode 100644 index 00000000000..48967198fd1 --- /dev/null +++ b/src/main/github/issue-mutation-source.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GhUtils from './gh-utils' + +const { ghExecFileAsyncMock, resolveIssueSourceMock, acquireMock, releaseMock } = vi.hoisted( + () => ({ + ghExecFileAsyncMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() + }) +) + +vi.mock('./gh-utils', async () => { + const actual = await vi.importActual('./gh-utils') + return { + ...actual, + ghExecFileAsync: ghExecFileAsyncMock, + acquire: acquireMock, + release: releaseMock + } +}) + +vi.mock('./github-api-repository', async (importOriginal) => ({ + ...(await importOriginal()), + resolveIssueGitHubApiRepositorySource: resolveIssueSourceMock +})) + +import { addIssueComment } from './issue-comment' +import { updateIssue } from './issue-update' + +const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } +const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' } + +describe('issue mutations follow the issue source preference', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + acquireMock.mockReset() + acquireMock.mockResolvedValue(undefined) + releaseMock.mockReset() + resolveIssueSourceMock.mockReset() + resolveIssueSourceMock.mockImplementation(async (_repoPath: string, preference: unknown) => ({ + source: preference === 'origin' ? ORIGIN : UPSTREAM, + fellBack: false + })) + }) + + it('posts issue comments to origin when origin is selected', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ id: 1, user: { login: 'octo', avatar_url: '' }, body: 'hi' }) + }) + + await expect( + addIssueComment('/repo-root', 5, 'hi', null, null, {}, 'origin') + ).resolves.toMatchObject({ ok: true }) + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( + 'repos/fork-owner/widgets/issues/5/comments' + ) + }) + + it('keeps an explicit owner/repo override ahead of the preference', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ id: 2, user: { login: 'octo', avatar_url: '' }, body: 'hi' }) + }) + + await addIssueComment('/repo-root', 7, 'hi', null, UPSTREAM, {}, 'origin') + + expect(resolveIssueSourceMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( + 'repos/upstream-owner/widgets/issues/7/comments' + ) + }) + + it('edits issues on origin when origin is selected', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await expect( + updateIssue('/repo-root', 5, { body: 'Updated', addAssignees: ['octo'] }, null, {}, 'origin') + ).resolves.toEqual({ ok: true }) + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/fork-owner/widgets/issues/5') + expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([ + 'issue', + 'edit', + '5', + '--repo', + 'fork-owner/widgets', + '--add-assignee', + 'octo' + ]) + }) + + it('keeps the upstream-first default when no preference is set', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await updateIssue('/repo-root', 5, { body: 'Updated' }) + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', undefined, undefined, {}) + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain('repos/upstream-owner/widgets/issues/5') + }) + + it('keeps edits on the opened issue when the saved preference changes', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await updateIssue( + '/repo-root', + 5, + { body: 'Origin edit', state: 'closed', addLabels: ['bug'], addAssignees: ['octo'] }, + null, + {}, + 'upstream', + ORIGIN + ) + + expect(resolveIssueSourceMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['issue', 'close', '5', '--repo', 'fork-owner/widgets'], + [ + 'api', + '-X', + 'PATCH', + 'repos/fork-owner/widgets/issues/5', + '--raw-field', + 'body=Origin edit' + ], + [ + 'issue', + 'edit', + '5', + '--repo', + 'fork-owner/widgets', + '--add-label', + 'bug', + '--add-assignee', + 'octo' + ] + ]) + }) + + it('preserves local WSL execution with an explicit issue repository', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await updateIssue( + '/home/fixture/widgets', + 5, + { body: 'Origin edit' }, + null, + { wslDistro: 'Ubuntu' }, + 'upstream', + ORIGIN + ) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['repos/fork-owner/widgets/issues/5']), + expect.objectContaining({ + cwd: '/home/fixture/widgets', + wslDistro: 'Ubuntu', + host: 'github.com' + }) + ) + }) + + it('refuses unresolved Origin mutations instead of using ambient gh', async () => { + resolveIssueSourceMock.mockResolvedValue({ source: null, fellBack: false }) + + await expect( + updateIssue('/repo-root', 5, { body: 'Origin edit' }, null, {}, 'origin') + ).resolves.toMatchObject({ ok: false }) + await expect( + addIssueComment('/repo-root', 5, 'Origin reply', null, null, {}, 'origin') + ).resolves.toMatchObject({ ok: false }) + + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/github/issue-update.ts b/src/main/github/issue-update.ts index 801c12b79c5..8b00b24a148 100644 --- a/src/main/github/issue-update.ts +++ b/src/main/github/issue-update.ts @@ -1,31 +1,35 @@ import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types' +import type { IssueSourcePreference } from '../../shared/repo-types' import type { LocalGitExecOptions } from './gh-utils' -import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository' +import { + resolveGitHubRepoExecution, + resolveIssueGitHubApiRepositorySource, + type GitHubApiRepository +} from './github-api-repository' import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils' -/** - * Update an existing GitHub issue. Fans out to separate gh commands for - * state changes vs field edits since `gh issue edit` does not support state. - * - * Why this path doesn't take a preference (mirrors `getIssue`): mutations - * target an issue number already bound to a worktree / linked elsewhere in - * the UI. Routing an update through the live per-repo preference would let - * a user open upstream#N, toggle the selector to origin, save, and silently - * write to origin#N — a different issue (or 404). That is the exact - * silent-source-switch class of wrongness #1186 / the parent design doc - * guard against. List and create paths honor preference; mutations stay on - * the heuristic `getIssueOwnerRepo`. - */ +// Use the selected source for issue-number edits. export async function updateIssue( repoPath: string, issueNumber: number, updates: GitHubIssueUpdate, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference, + ownerRepoOverride?: GitHubApiRepository | null ): Promise<{ ok: true } | { ok: false; error: string }> { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, - () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), + ownerRepoOverride ?? + (async () => + ( + await resolveIssueGitHubApiRepositorySource( + repoPath, + preference, + connectionId, + localGitOptions + ) + ).source), connectionId, localGitOptions ) diff --git a/src/main/github/work-item-details-concurrency.test.ts b/src/main/github/work-item-details-concurrency.test.ts index 3fdbacb6491..d7a0671470e 100644 --- a/src/main/github/work-item-details-concurrency.test.ts +++ b/src/main/github/work-item-details-concurrency.test.ts @@ -1,3 +1,4 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -27,8 +28,11 @@ vi.mock('./gh-utils', () => ({ }) })) -vi.mock('./client', () => ({ +vi.mock('./client', async () => ({ getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), getPRComments: getPRCommentsMock, getPRChecks: getPRChecksMock })) diff --git a/src/main/github/work-item-details-config-change.test.ts b/src/main/github/work-item-details-config-change.test.ts new file mode 100644 index 00000000000..f10ecfe0046 --- /dev/null +++ b/src/main/github/work-item-details-config-change.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type * as NodeOs from 'node:os' +import type * as GitExecError from '../git/exec-error' +import type * as WorkItemLookup from './client/fetch/get-work-item' +import { runProcess } from '../../shared/child-process/run-process' + +const { ghMock, fixture } = vi.hoisted(() => { + const fixture: { + path: string + initialHost: string + replacementUrl: string + requests: { args: string[]; host?: string }[] + } = { path: '', initialHost: '', replacementUrl: '', requests: [] } + return { ghMock: vi.fn(), fixture } +}) + +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => join(fixture.path, 'isolated-home') +})) + +vi.mock('../git/runner', async () => { + const errors = await vi.importActual('../git/exec-error') + return { + ...errors, + ghExecFileAsync: ghMock, + gitExecFileAsync: async (args: string[], options: { cwd?: string }) => { + const result = await runProcess({ + program: 'git', + args, + cwd: options.cwd, + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig') + } + }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return { stdout: result.stdout, stderr: result.stderr } + } + } +}) + +vi.mock('./client', async () => ({ + ...(await vi.importActual('./client/fetch/get-work-item')), + getPRChecks: vi.fn(), + getPRComments: vi.fn() +})) + +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: () => ({ blocked: false }), + noteRepositoryRateLimitSpend: vi.fn() +})) + +import { getWorkItemDetails } from './work-item-details' +import { _resetOwnerRepoCache } from './github-repository-identity' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' +import { _resetGitHubHostAuthCache } from './github-enterprise-repository' + +async function fixtureGit(args: string[]): Promise { + const result = await runProcess({ + program: 'git', + args, + cwd: fixture.path, + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: join(fixture.path, 'isolated-home', '.gitconfig') + } + }) + expect(result.code, result.stderr).toBe(0) +} + +beforeEach(async () => { + fixture.path = await mkdtemp(join(tmpdir(), 'orca-issue-config-change-')) + await mkdir(join(fixture.path, 'isolated-home')) + await fixtureGit(['init', '--quiet']) + await fixtureGit(['remote', 'add', 'origin', 'https://github.com/fork-owner/widgets.git']) + _resetOwnerRepoCache() + _resetOriginGitHubApiRepositoryCache() + _resetGitHubHostAuthCache() + fixture.initialHost = 'github.com' + fixture.replacementUrl = 'https://github.com/replacement-owner/widgets.git' + fixture.requests = [] + ghMock.mockReset() + ghMock.mockImplementation(async (args: string[], options: { host?: string }) => { + fixture.requests.push({ args, host: options.host }) + if (args[0] === 'auth' && args[1] === 'status') { + return { + stdout: ['github.com', 'github.enterprise.test'] + .map((host) => `${host}\n Logged in to ${host} account fixture (keyring)`) + .join('\n'), + stderr: '' + } + } + if (args.includes('repos/fork-owner/widgets/issues/5')) { + await fixtureGit(['remote', 'set-url', 'origin', fixture.replacementUrl]) + return { + stdout: JSON.stringify({ + number: 5, + title: 'ORIGIN title', + state: 'open', + html_url: `https://${fixture.initialHost}/fork-owner/widgets/issues/5`, + labels: [], + updated_at: '2026-10-02T00:00:00Z', + user: { login: 'fork-author' } + }), + stderr: '' + } + } + if (args.includes('graphql')) { + const original = args.includes('owner=fork-owner') && options.host === fixture.initialHost + const marker = original ? 'ORIGIN' : 'REPLACEMENT' + return { + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: `${marker} body`, + assignees: { nodes: [{ login: `${marker}-assignee` }] }, + participants: { nodes: [] }, + comments: { + nodes: [ + { + databaseId: 1, + body: `${marker} comment`, + createdAt: '2026-10-02T00:00:00Z', + url: `https://${options.host}/fork-owner/widgets/issues/5#issuecomment-1`, + author: { login: `${marker}-author`, avatarUrl: '' } + } + ] + } + } + } + } + }), + stderr: '' + } + } + if (args.some((arg) => arg.includes('/timeline?'))) { + return { stdout: '[]', stderr: '' } + } + throw new Error(`Unexpected fixture gh request: ${args.join(' ')}`) + }) +}) + +afterEach(async () => { + await rm(fixture.path, { recursive: true, force: true }) +}) + +it.each(['issue', undefined] as const)( + 'keeps implicit %s conversation on the repository used for the item fetch', + async (type) => { + const details = await getWorkItemDetails(fixture.path, 5, type, null, {}, 'origin') + expect(details?.item.title).toBe('ORIGIN title') + expect(details?.body).toBe('ORIGIN body') + expect(details?.assignees).toEqual(['ORIGIN-assignee']) + expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment']) + expect(fixture.requests[1].args).toContain('owner=fork-owner') + } +) + +it('keeps an explicit local Tasks identity through the same real config change', async () => { + const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', { + owner: 'fork-owner', + repo: 'widgets', + host: 'github.com' + }) + expect(details?.item.title).toBe('ORIGIN title') + expect(details?.body).toBe('ORIGIN body') +}) + +it.each([ + { from: 'github.com', to: 'github.enterprise.test', qualified: false }, + { from: 'github.enterprise.test', to: 'github.com', qualified: false }, + { from: 'github.com', to: 'github.enterprise.test', qualified: true }, + { from: 'github.enterprise.test', to: 'github.com', qualified: true } +])( + 'keeps explicit host from $from to $to (qualified=$qualified)', + async ({ from, to, qualified }) => { + fixture.initialHost = from + fixture.replacementUrl = `https://${to}/fork-owner/widgets.git` + await fixtureGit(['remote', 'set-url', 'origin', `https://${from}/fork-owner/widgets.git`]) + const details = await getWorkItemDetails(fixture.path, 5, 'issue', null, {}, 'origin', { + owner: 'fork-owner', + repo: 'widgets', + ...(qualified ? { host: from } : {}) + }) + expect(details?.item.url).toBe(`https://${from}/fork-owner/widgets/issues/5`) + expect(details?.body).toBe('ORIGIN body') + expect(details?.assignees).toEqual(['ORIGIN-assignee']) + expect(details?.comments.map((comment) => comment.body)).toEqual(['ORIGIN comment']) + const conversationRequests = fixture.requests.filter( + ({ args }) => args.includes('graphql') || args.some((arg) => arg.includes('/timeline?')) + ) + expect(conversationRequests.some(({ args }) => args.includes('graphql'))).toBe(true) + expect( + conversationRequests.some(({ args }) => args.some((arg) => arg.includes('/timeline?'))) + ).toBe(true) + expect(conversationRequests.map(({ host }) => host)).toEqual([from, from]) + } +) diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts index be2576051b5..b615f4e566b 100644 --- a/src/main/github/work-item-details-enterprise-host.test.ts +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -1,3 +1,4 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = @@ -47,8 +48,11 @@ vi.mock('./gh-utils', () => ({ release: vi.fn() })) -vi.mock('./client', () => ({ +vi.mock('./client', async () => ({ getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock diff --git a/src/main/github/work-item-details-file-viewed.test.ts b/src/main/github/work-item-details-file-viewed.test.ts index 6075803493c..7cbfa041608 100644 --- a/src/main/github/work-item-details-file-viewed.test.ts +++ b/src/main/github/work-item-details-file-viewed.test.ts @@ -1,3 +1,4 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = @@ -59,8 +60,11 @@ vi.mock('./gh-utils', () => ({ release: releaseMock })) -vi.mock('./client', () => ({ +vi.mock('./client', async () => ({ getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), getWorkItemByOwnerRepo: vi.fn(), getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock diff --git a/src/main/github/work-item-details-issue-source.test.ts b/src/main/github/work-item-details-issue-source.test.ts new file mode 100644 index 00000000000..e52de876d18 --- /dev/null +++ b/src/main/github/work-item-details-issue-source.test.ts @@ -0,0 +1,239 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getOwnerRepoForRemoteMock, + getWorkItemMock, + getWorkItemByOwnerRepoMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + getWorkItemMock: vi.fn(), + getWorkItemByOwnerRepoMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepo: getOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, + ghRepoExecOptions: vi.fn((context) => ({ cwd: context.repoPath })), + githubRepoContext: vi.fn((repoPath, connectionId, localGitOptions) => ({ + repoPath, + connectionId: connectionId ?? null, + ...localGitOptions + })), + acquire: acquireMock, + release: releaseMock +})) + +vi.mock('./client', async () => ({ + getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), + getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, + getPRChecks: vi.fn(), + getPRComments: vi.fn() +})) + +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: vi.fn().mockResolvedValue(null), + getEnterpriseGitHubRepoSlugForRemote: vi.fn().mockResolvedValue(null), + isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) +})) + +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn() +})) + +vi.mock('../git/remote-name-listing', () => ({ + shouldProbeGitRemote: vi.fn().mockResolvedValue(true) +})) + +import { getWorkItemDetails } from './work-item-details' +import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' + +const REMOTES: Record = { + origin: { owner: 'fork-owner', repo: 'widgets' }, + upstream: { owner: 'upstream-owner', repo: 'widgets' } +} + +function graphQLIssueResponse(assignee: string): { stdout: string } { + return { + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: `${assignee} body`, + assignees: { nodes: [{ login: assignee }] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + } +} + +function graphQLOwnerArg(): string | undefined { + const args: string[] = ghExecFileAsyncMock.mock.calls[0][0] + return args.find((arg) => arg.startsWith('owner=')) +} + +describe('getWorkItemDetails issue source', () => { + beforeEach(() => { + _resetOriginGitHubApiRepositoryCache() + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getOwnerRepoMock.mockResolvedValue(REMOTES.origin) + getOwnerRepoForRemoteMock.mockReset() + getOwnerRepoForRemoteMock.mockImplementation( + async (_repoPath: string, remoteName: string) => REMOTES[remoteName] ?? null + ) + getWorkItemMock.mockReset() + getWorkItemByOwnerRepoMock.mockReset() + getWorkItemMock.mockResolvedValue({ + id: 'issue:5', + type: 'issue', + number: 5, + title: 'Origin issue', + state: 'open', + url: 'https://github.com/fork-owner/widgets/issues/5', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'fork-author' + }) + acquireMock.mockReset() + acquireMock.mockResolvedValue(undefined) + releaseMock.mockReset() + }) + + it('reads body, assignees and comments from origin when origin is selected', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee')) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin') + + expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 5, 'issue', null, {}, 'origin') + expect(graphQLOwnerArg()).toBe('owner=fork-owner') + expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain( + 'repos/fork-owner/widgets/issues/5/timeline?per_page=100&page=1' + ) + expect(details?.assignees).toEqual(['fork-assignee']) + }) + + it('keeps the upstream-first default when no preference is set', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee')) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}) + + expect(graphQLOwnerArg()).toBe('owner=upstream-owner') + expect(details?.assignees).toEqual(['upstream-assignee']) + }) + + it('keeps a Project row on upstream even when Origin is selected', async () => { + const upstream = { ...REMOTES.upstream, host: 'github.com' } + getWorkItemByOwnerRepoMock.mockResolvedValue({ + id: 'issue:5', + type: 'issue', + number: 5, + title: 'Upstream project issue', + url: 'https://github.com/upstream-owner/widgets/issues/5', + state: 'open', + labels: [], + updatedAt: '', + author: 'upstream-author' + }) + ghExecFileAsyncMock + .mockResolvedValueOnce(graphQLIssueResponse('upstream-assignee')) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'origin', upstream) + + expect(getWorkItemMock).not.toHaveBeenCalled() + expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith( + '/repo-root', + upstream, + 5, + 'issue', + null, + {} + ) + expect(graphQLOwnerArg()).toBe('owner=upstream-owner') + expect(details?.item.title).toBe('Upstream project issue') + expect(details?.assignees).toEqual(['upstream-assignee']) + }) + + it('does not fall back when the selected issue repository is unavailable', async () => { + getWorkItemByOwnerRepoMock.mockResolvedValue(null) + + await expect( + getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', { + ...REMOTES.origin, + host: 'github.com' + }) + ).resolves.toBeNull() + + expect(getWorkItemMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('rejects an invalid runtime issue host before calling the repository lookup', async () => { + await expect( + getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', { + ...REMOTES.origin, + host: 42 + }) + ).resolves.toBeNull() + + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(getWorkItemMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('ignores an issue repository override on an untyped lookup', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee')) + .mockResolvedValueOnce({ stdout: '' }) + + await getWorkItemDetails('/repo-root', 5, undefined, null, {}, 'origin', { + owner: 'untrusted', + repo: 'other', + host: 'untrusted.example' + }) + + expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() + expect(graphQLOwnerArg()).toBe('owner=fork-owner') + expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' }) + }) + + it('normalizes host-less issue targets before loading the conversation', async () => { + getWorkItemByOwnerRepoMock.mockResolvedValue({ + type: 'issue', + number: 5, + title: 'Origin issue', + url: 'https://github.com/fork-owner/widgets/issues/5', + author: null + }) + ghExecFileAsyncMock + .mockResolvedValueOnce(graphQLIssueResponse('fork-assignee')) + .mockResolvedValueOnce({ stdout: '' }) + + await getWorkItemDetails('/repo-root', 5, 'issue', null, {}, 'upstream', REMOTES.origin) + + expect(graphQLOwnerArg()).toBe('owner=fork-owner') + expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ host: 'github.com' }) + expect(ghExecFileAsyncMock.mock.calls[1][1]).toMatchObject({ host: 'github.com' }) + }) +}) diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts index d0205332bd5..c7af8abac85 100644 --- a/src/main/github/work-item-details-pr-files.test.ts +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -1,3 +1,4 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = @@ -52,8 +53,11 @@ vi.mock('./gh-utils', () => ({ release: vi.fn() })) -vi.mock('./client', () => ({ +vi.mock('./client', async () => ({ getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), getWorkItemByOwnerRepo: vi.fn(), getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock diff --git a/src/main/github/work-item-details-test-lookup.ts b/src/main/github/work-item-details-test-lookup.ts new file mode 100644 index 00000000000..69c1daf0777 --- /dev/null +++ b/src/main/github/work-item-details-test-lookup.ts @@ -0,0 +1,17 @@ +import { resolveIssueGitHubApiRepositorySource } from './github-api-repository' +import type { getWorkItem } from './client' +import type { WorkItemRepositoryLookup } from './client/fetch/get-work-item' + +// Why: detail tests replace item lookup; retain its selected-source metadata. +export function makeWorkItemDetailsLookupMock(lookup: typeof getWorkItem) { + return async (...args: Parameters): Promise => { + const item = await lookup(...args) + return { + item, + repository: + item?.type === 'issue' + ? (await resolveIssueGitHubApiRepositorySource(args[0], args[5], args[3], args[4])).source + : null + } + } +} diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 3ec1a5a4d9f..ef2d3649818 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -1,3 +1,4 @@ +import type * as WorkItemDetailsTestLookup from './work-item-details-test-lookup' import { beforeEach, describe, expect, it, vi } from 'vitest' type RateLimitGuardResult = @@ -62,8 +63,11 @@ vi.mock('./gh-utils', () => ({ release: releaseMock })) -vi.mock('./client', () => ({ +vi.mock('./client', async () => ({ getWorkItem: getWorkItemMock, + getWorkItemWithRepository: ( + await vi.importActual('./work-item-details-test-lookup') + ).makeWorkItemDetailsLookupMock(getWorkItemMock), getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock, getPRChecks: getPRChecksMock, getPRComments: getPRCommentsMock diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index dafc4f514d6..385d66c9515 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -1,14 +1,16 @@ import type { PRCheckDetail } from '../../shared/github/check-types' import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' -import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types' +import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types' import type { IssueSourcePreference } from '../../shared/repo-types' -import { getPRChecks, getPRComments, getWorkItem } from './client' -import { acquire, release, type LocalGitExecOptions } from './gh-utils' import { - getIssueGitHubApiRepository, - resolveGitHubRepoExecution, - type GitHubApiRepository -} from './github-api-repository' + getPRChecks, + getPRComments, + getWorkItemWithRepository, + getWorkItemByOwnerRepo +} from './client' +import { acquire, release, type LocalGitExecOptions } from './gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository' +import { isValidGitHubApiRepository } from './github-api-repository-validation' import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details' import { getPRFiles, @@ -69,23 +71,49 @@ export async function getWorkItemDetails( type?: 'issue' | 'pr', connectionId?: string | null, localGitOptions: LocalGitExecOptions = {}, - preference?: IssueSourcePreference + preference?: IssueSourcePreference, + repositoryOverride?: unknown ): Promise { - const item: Omit | null = await getWorkItem( - repoPath, - number, - type, - connectionId, - localGitOptions, - preference - ) + let issueRepositoryOverride: GitHubApiRepository | null = null + if (type === 'issue' && repositoryOverride != null) { + if (!isValidGitHubApiRepository(repositoryOverride)) { + return null + } + issueRepositoryOverride = ( + await resolveGitHubRepoExecution(repoPath, repositoryOverride, connectionId, localGitOptions) + ).ownerRepo + if (!issueRepositoryOverride) { + return null + } + } + const { item, repository: selectedRepository } = issueRepositoryOverride + ? { + item: await getWorkItemByOwnerRepo( + repoPath, + issueRepositoryOverride, + number, + 'issue', + connectionId, + localGitOptions + ), + repository: issueRepositoryOverride + } + : await getWorkItemWithRepository( + repoPath, + number, + type, + connectionId, + localGitOptions, + preference + ) if (!item) { return null } + // Keep the conversation in the same repository as the selected issue. const resolvedRepository = item.type === 'issue' - ? await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions) + ? selectedRepository : (await resolveGitHubRepoExecution(repoPath, item.prRepo, connectionId, localGitOptions)) .ownerRepo diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 907092515f5..664bbaf1aa3 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/bitbucket/client.ts', 1], ['main/bitbucket/user-request.ts', 1], ['main/gitea/client.ts', 1], + // Generated OpenCode claim source consumes JSON or cancels its body in finally. + ['main/opencode/opencode-startup-prompt-source.ts', 1], ['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], diff --git a/src/main/ipc/command-path-resolver.test.ts b/src/main/ipc/command-path-resolver.test.ts index 9172d1c2465..9e1c9cd7d5c 100644 --- a/src/main/ipc/command-path-resolver.test.ts +++ b/src/main/ipc/command-path-resolver.test.ts @@ -1,8 +1,8 @@ -import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { access, chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { isCommandOnLocalPath } from './command-path-resolver' +import { isCommandOnLocalPath, listLocalCommandPaths } from './command-path-resolver' describe('isCommandOnLocalPath', () => { it('returns false for an empty command', async () => { @@ -112,4 +112,129 @@ describe('isCommandOnLocalPath', () => { ).resolves.toBe(true) }) }) + + // Why the full list exists (#22975): a dead version-manager shim passes the + // same executable check as the binary it shadows, so a winner-only lookup can + // only ever hand the caller the shim. The copies behind it are the answer. + describe.skipIf(process.platform === 'win32')('listLocalCommandPaths', () => { + let front = '' + let middle = '' + let back = '' + + async function executable(dir: string, name: string, body: string): Promise { + await writeFile(path.join(dir, name), body) + await chmod(path.join(dir, name), 0o755) + } + + beforeAll(async () => { + front = await mkdtemp(path.join(tmpdir(), 'cmd-list-front-')) + middle = await mkdtemp(path.join(tmpdir(), 'cmd-list-middle-')) + back = await mkdtemp(path.join(tmpdir(), 'cmd-list-back-')) + await executable(front, 'gh', '#!/usr/bin/env bash\nexec /nope/asdf exec "gh" "$@"\n') + await executable(back, 'gh', "#!/bin/sh\nprintf 'gh version 2.98.0\\n'\n") + // Both a non-executable file and a directory in the middle: neither is a + // match, and neither may end the scan. + await writeFile(path.join(middle, 'gh'), 'not executable\n') + await chmod(path.join(middle, 'gh'), 0o644) + await mkdir(path.join(middle, 'glab')) + await executable(back, 'glab', '#!/bin/sh\n') + }) + + afterAll(async () => { + await rm(front, { recursive: true, force: true }) + await rm(middle, { recursive: true, force: true }) + await rm(back, { recursive: true, force: true }) + }) + + it('returns every match in PATH order, shim first', async () => { + await expect( + listLocalCommandPaths('gh', { + platform: 'linux', + env: { PATH: `${front}:${middle}:${back}` } + }) + ).resolves.toEqual([path.posix.join(front, 'gh'), path.posix.join(back, 'gh')]) + }) + + it('follows PATH order rather than which copy runs', async () => { + await expect( + listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: `${back}:${front}` } }) + ).resolves.toEqual([path.posix.join(back, 'gh'), path.posix.join(front, 'gh')]) + }) + + it('skips a directory that matches the command name and keeps scanning', async () => { + await expect( + listLocalCommandPaths('glab', { + platform: 'linux', + env: { PATH: `${middle}:${front}:${back}` } + }) + ).resolves.toEqual([path.posix.join(back, 'glab')]) + }) + + it('returns an empty list when PATH holds no absolute match', async () => { + await expect( + listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: '' } }) + ).resolves.toEqual([]) + }) + + it('applies the absolute-only gate to the whole list, not just the winner', async () => { + await expect( + listLocalCommandPaths('gh', { + platform: 'linux', + env: { PATH: `.${path.delimiter}${front}` } + }) + ).resolves.toEqual([path.posix.join(front, 'gh')]) + }) + + it('drops a relative match the filesystem would otherwise find', async () => { + // Why a second relative case: `path.posix.join('.', 'gh')` collapses to + // `gh`, so the case above is unaffected by deleting the gate. `fs.access` + // resolves a relative candidate against the process cwd, so only a PATH + // entry that really reaches a fixture proves the filter — and the `access` + // proves that reach first, making this fail loudly instead of going + // vacuous if the suite is ever run from a directory that hides it. + const relative = path.relative(process.cwd(), back) + await expect(access(path.join(relative, 'gh'))).resolves.toBeUndefined() + await expect( + listLocalCommandPaths('gh', { platform: 'linux', env: { PATH: relative } }) + ).resolves.toEqual([]) + }) + + it('lists a PATH entry that repeats only once', async () => { + // Why it matters: callers spawn each entry, and `/usr/local/bin` appearing + // twice in a real PATH is normal — a doomed shim must not be probed twice. + await expect( + listLocalCommandPaths('gh', { + platform: 'linux', + env: { PATH: `${back}:${front}:${back}` } + }) + ).resolves.toEqual([path.posix.join(back, 'gh'), path.posix.join(front, 'gh')]) + }) + + it('returns an empty list for an empty command', async () => { + await expect(listLocalCommandPaths('')).resolves.toEqual([]) + }) + + it('resolves an absolute command path directly', async () => { + await expect( + listLocalCommandPaths(path.join(front, 'gh'), { platform: 'linux', env: { PATH: '' } }) + ).resolves.toEqual([`${front}/gh`]) + }) + + it('lists every PATHEXT permutation on win32, in resolver order', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'cmd-list-win32-')) + try { + await writeFile(path.join(dir, 'tool.CMD'), '@echo off\n') + await writeFile(path.join(dir, 'tool.EXE'), '') + await expect( + listLocalCommandPaths('tool', { + platform: 'win32', + env: { Path: dir, PATHEXT: '.CMD;.EXE' }, + cwd: dir + }) + ).resolves.toEqual([`${dir}/tool.CMD`, `${dir}/tool.EXE`]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + }) }) diff --git a/src/main/ipc/command-path-resolver.ts b/src/main/ipc/command-path-resolver.ts index 1f2934ba8b2..27808b6aded 100644 --- a/src/main/ipc/command-path-resolver.ts +++ b/src/main/ipc/command-path-resolver.ts @@ -1,4 +1,6 @@ import { access, constants as fsConstants, stat } from 'node:fs/promises' +import { statSync, type Stats } from 'node:fs' +import { homedir } from 'node:os' import path from 'node:path' export type ResolveCommandOptions = { @@ -8,6 +10,8 @@ export type ResolveCommandOptions = { env?: NodeJS.ProcessEnv /** CWD used only for the win32 "search current directory first" rule. */ cwd?: string + /** Stop after this many matches; defaults to the complete list. */ + maxResults?: number } // Why: Windows env keys are case-insensitive (PATH is usually stored as `Path`, @@ -38,6 +42,109 @@ function getWindowsExtensions(env: NodeJS.ProcessEnv, command: string): string[] return extensions } +type LocalCommandSelection = { + scope: string + selected?: { binary: string; stamp: string; cwd?: string } +} + +const localCommandSelections = new Map() + +function selectionScope(options: ResolveCommandOptions): string { + const platform = options.platform ?? process.platform + const env = options.env ?? process.env + const isWin = platform === 'win32' + const pathValue = readEnvCaseInsensitive(env, 'PATH') ?? '' + const pathApi = isWin ? path.win32 : path.posix + const needsCwd = pathValue.split(isWin ? ';' : ':').some((dir) => !pathApi.isAbsolute(dir)) + return JSON.stringify([ + platform, + pathValue, + isWin ? readEnvCaseInsensitive(env, 'PATHEXT') : null, + env.HOME, + env.USERPROFILE, + homedir(), + needsCwd ? (options.cwd ?? process.cwd()) : null + ]) +} + +function commandFileStamp(stats: Stats): string { + return [stats.dev, stats.ino, stats.size, stats.mtimeMs, stats.ctimeMs, stats.mode].join(':') +} + +/** Publish only a successful version probe; a newer probe supersedes an older one. */ +export function beginLocalCommandSelection( + command: string +): (binary: string | null) => Promise { + if (command !== 'gh' && command !== 'glab') { + return async () => {} + } + const scope = selectionScope({}) + const probeCwd = process.cwd() + const previous = localCommandSelections.get(command) + const selection: LocalCommandSelection = { + scope, + selected: previous?.scope === scope ? previous.selected : undefined + } + localCommandSelections.set(command, selection) + return async (binary) => { + if (localCommandSelections.get(command) !== selection) { + return + } + if (binary === null || !path.isAbsolute(binary)) { + delete selection.selected + return + } + try { + const stats = await stat(binary) + if (localCommandSelections.get(command) === selection) { + const cwd = + process.platform === 'win32' && + path.win32.resolve(path.win32.dirname(binary)).toLowerCase() === + path.win32.resolve(probeCwd).toLowerCase() + ? probeCwd + : undefined + // Only a current-directory CLI needs to stay tied to the probe's folder. + selection.selected = stats.isFile() + ? { binary, stamp: commandFileStamp(stats), cwd } + : undefined + } + } catch { + // A binary removed during the probe must not become the runtime selection. + if (localCommandSelections.get(command) === selection) { + delete selection.selected + } + } + } +} + +/** Native execution reuses preflight's selection without probing or replaying the operation. */ +export function resolveSelectedLocalCommand( + command: string, + options: ResolveCommandOptions = {} +): string { + const selection = localCommandSelections.get(command) + if (!selection?.selected || selection.scope !== selectionScope(options)) { + return command + } + if ( + selection.selected.cwd && + path.win32.resolve(options.cwd ?? process.cwd()).toLowerCase() !== + path.win32.resolve(selection.selected.cwd).toLowerCase() + ) { + return command + } + try { + const stats = statSync(selection.selected.binary) + if (commandFileStamp(stats) === selection.selected.stamp) { + return selection.selected.binary + } + } catch { + // Missing or replaced binaries require a fresh version probe. + } + delete selection.selected + return command +} + async function isExecutableFile(candidate: string, isWin: boolean): Promise { try { // Why: stat (not lstat) so symlinked CLIs resolve to their real target. @@ -63,12 +170,15 @@ async function isExecutableFile(candidate: string, isWin: boolean): Promise { - return (await resolveCommandOnLocalPath(command, options)) !== null + return (await findLocalCommandPaths(command, options, true)).length > 0 } /** The absolute path `isCommandOnLocalPath` found, or null. */ @@ -76,8 +186,24 @@ export async function resolveCommandOnLocalPath( command: string, options: ResolveCommandOptions = {} ): Promise { + return (await findLocalCommandPaths(command, options, true))[0] ?? null +} + +/** Ordered, deduplicated candidates, including executable shims that may fail to run. */ +export async function listLocalCommandPaths( + command: string, + options: ResolveCommandOptions = {} +): Promise { + return findLocalCommandPaths(command, options, false) +} + +async function findLocalCommandPaths( + command: string, + options: ResolveCommandOptions, + stopAtFirst: boolean +): Promise { if (!command) { - return null + return [] } const platform = options.platform ?? process.platform const env = options.env ?? process.env @@ -95,6 +221,8 @@ export async function resolveCommandOnLocalPath( const searchDirs = hasPathSeparator ? [''] : isWin ? [cwd, ...pathDirs] : pathDirs const extensions = isWin ? getWindowsExtensions(env, command) : [''] + const found: string[] = [] + const seen = new Set() for (const dir of searchDirs) { for (const ext of extensions) { // Why: forward-slash joins so candidates are statable on every platform @@ -102,13 +230,18 @@ export async function resolveCommandOnLocalPath( const candidate = path.posix.join(dir, command) + ext // Why: preserve the prior `.some(line => path.isAbsolute(line))` filter // over where/which stdout — only absolute resolutions count. - if (!isAbsolute(candidate)) { + const candidateKey = isWin ? candidate.toLowerCase() : candidate + if (!isAbsolute(candidate) || seen.has(candidateKey)) { continue } + seen.add(candidateKey) if (await isExecutableFile(candidate, isWin)) { - return candidate + found.push(candidate) + if (stopAtFirst || found.length >= (options.maxResults ?? Infinity)) { + return found + } } } } - return null + return found } diff --git a/src/main/ipc/filesystem-list-files-name-filter.test.ts b/src/main/ipc/filesystem-list-files-name-filter.test.ts index b529dd34b39..e22043a4d69 100644 --- a/src/main/ipc/filesystem-list-files-name-filter.test.ts +++ b/src/main/ipc/filesystem-list-files-name-filter.test.ts @@ -62,7 +62,7 @@ describe('listQuickOpenFiles name filter', () => { it('counts only matches against the ripgrep cap', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('a.ts\0b.ts\0c.ts\0ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('')) const files = await listQuickOpenFiles( @@ -80,7 +80,7 @@ describe('listQuickOpenFiles name filter', () => { it('rejects with the bundled-ripgrep error when the filtered ignored pass cannot start', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('', null, -2)) await expect( @@ -98,7 +98,7 @@ describe('listQuickOpenFiles name filter', () => { it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL')) await expect( @@ -116,7 +116,7 @@ describe('listQuickOpenFiles name filter', () => { it('still rejects an ignored-pass failure for unfiltered listings', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('a.ts\n')) + .mockImplementationOnce(() => fakeRipgrep('a.ts\0')) .mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL')) await expect( diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index e9a4d406d7f..b6fe3453c22 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -88,6 +88,33 @@ describe('filesystem-list-files', () => { ) }) + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const store: Store = Object.create(null) + const promise = listQuickOpenFiles('/repo', store) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + + it('counts NUL-delimited filenames containing newlines as one result each', async () => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const store: Store = Object.create(null) + const promise = listQuickOpenFiles('/repo', store, undefined, undefined, 2) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + child.stdout?.emit('data', 'first\nsecond.ts\0trailing\r\0third.ts\0') + await expect(promise).resolves.toEqual(['first\nsecond.ts', 'trailing\r']) + expect(child.kill).toHaveBeenCalled() + }) + it('rejects a synchronous launch failure before cleanup has been initialized', async () => { spawnMock.mockImplementationOnce(() => { throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' }) @@ -122,7 +149,7 @@ describe('filesystem-list-files', () => { ) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts') + p1.stdout?.emit('data', 'one.ts\0two.ts') p1.emit('close', 0, null) }, 0) const result = await promise @@ -136,38 +163,60 @@ describe('filesystem-list-files', () => { expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version') }) - it('merges normal files and ignored files and filters correctly', async () => { - const p1 = createMockProcess() - const p2 = createMockProcess() + it('keeps source files first when only a serialized byte budget is provided', async () => { + const source = createMockProcess() + const broad = createMockProcess() + spawnMock.mockImplementation((_command, args: string[]) => + isIgnoredRgPass(args) ? broad : source + ) + const listing = listQuickOpenFiles( + '/mock/root', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization and workspace lookup are mocked above. + {} as Store, + undefined, + undefined, + undefined, + 20 + ) + await flushMicrotasks() + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs') + source.stdout?.emit('data', 'source.ts\0') + source.emit('close', 0, null) + await flushMicrotasks() + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs') + broad.stdout?.emit('data', 'ignored-file.ts\0') + await expect(listing).resolves.toEqual(['source.ts']) + expect(broad.kill).toHaveBeenCalledOnce() + }) - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + it('lists normal and ignored files with one broad scan and filters correctly', async () => { + const p1 = createMockProcess() + + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) await flushMicrotasks() - expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0]?.[1]).toContain('--no-ignore-vcs') // Simulate stdout output for normal files setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', 'node_modules/bad.js\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.git/config\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.github/workflows/ci.yml\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', 'dir1/') // incomplete line - ;(p1.stdout as unknown as EventEmitter).emit('data', 'file2.js\n') - p1.emit('close', 0, null) + p1.stdout?.emit('data', 'file1.ts\0') + p1.stdout?.emit('data', 'node_modules/bad.js\0') + p1.stdout?.emit('data', '.git/config\0') + p1.stdout?.emit('data', '.github/workflows/ci.yml\0') + p1.stdout?.emit('data', 'dir1/') // incomplete line + p1.stdout?.emit('data', 'file2.js\0') - // Simulate stdout output for ignored files - ;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n') - ;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate - ;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n') - p2.emit('close', 0, null) + // The broad pass includes ignored files too. + p1.stdout?.emit('data', '.env.local\0') + p1.stdout?.emit('data', 'dist/generated.js\0') + p1.stdout?.emit('data', 'file1.ts\0') // Duplicate + p1.stdout?.emit('data', 'node_modules/ignored.js\0') + p1.emit('close', 0, null) }, 10) const result = await promise @@ -183,23 +232,16 @@ describe('filesystem-list-files', () => { it('spawns the bundled Linux rg inside the registered WSL runtime for Windows-path worktrees', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('C:\\repo', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + p1.stdout?.emit('data', 'src/index.ts\0') p1.emit('close', 0, null) - p2.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -214,23 +256,16 @@ describe('filesystem-list-files', () => { it('normalizes absolute WSL rg output for Windows-path worktrees', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('C:\\repo', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n') + p1.stdout?.emit('data', '/mnt/c/repo/src/index.ts\0') p1.emit('close', 0, null) - p2.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -238,16 +273,13 @@ describe('filesystem-list-files', () => { it('treats a WSL launcher exit 127 as the bundled rg failing to start', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() Object.defineProperty(p1, 'pid', { value: 1 }) - Object.defineProperty(p2, 'pid', { value: 2 }) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) - spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + spawnMock.mockReturnValue(p1) const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store) setTimeout(() => { p1.emit('close', 127, null) - p2.emit('close', 0, null) }, 0) await expect(promise).rejects.toThrow(BUNDLED_ERROR) @@ -256,48 +288,33 @@ describe('filesystem-list-files', () => { it("rejects with the bundled-ripgrep error when a native launcher exits outside ripgrep's contract", async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + spawnMock.mockReturnValue(p1) const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) setTimeout(() => p1.emit('close', 127, null), 0) await expect(promise).rejects.toThrow(BUNDLED_ERROR) - expect(p2.kill).toHaveBeenCalled() }) it('rejects rg failures instead of resolving a false-empty list', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { p1.emit('close', 2, null) - p2.emit('close', 0, null) }, 10) await expect(promise).rejects.toThrow('rg exited with code 2') }) - it('kills the sibling rg pass after one pass fails', async () => { + it('does not start another scan after the admitted pass fails', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) @@ -308,27 +325,20 @@ describe('filesystem-list-files', () => { }, 10) await expect(promise).rejects.toThrow('rg exited with code 2') - expect(p2.kill).toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) }) it('accepts rg code 2 when rg emitted parseable paths first', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + p1.stdout?.emit('data', 'src/index.ts\0') p1.emit('close', 2, null) - p2.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -339,14 +349,8 @@ describe('filesystem-list-files', () => { try { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) @@ -355,14 +359,13 @@ describe('filesystem-list-files', () => { await Promise.resolve() await Promise.resolve() - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial') + p1.stdout?.emit('data', 'src/index.ts\0partial') const rejection = expect(promise).rejects.toThrow('rg list timed out') await vi.advanceTimersByTimeAsync(10000) await rejection expect(p1.kill).toHaveBeenCalled() - expect(p2.kill).toHaveBeenCalled() expect((p1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((p1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(p1.listenerCount('error')).toBe(0) @@ -374,8 +377,7 @@ describe('filesystem-list-files', () => { it('kills local rg scans when a paired listing is cancelled', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + spawnMock.mockReturnValue(p1) const controller = new AbortController() const cancellation = new FileListingCancelledError('superseded') const promise = listQuickOpenFiles( @@ -390,34 +392,24 @@ describe('filesystem-list-files', () => { await expect(promise).rejects.toBe(cancellation) expect(p1.kill).toHaveBeenCalledOnce() - expect(p2.kill).toHaveBeenCalledOnce() }) it('filters out .next, .cache, .stably, .vscode, .idea', async () => { const p1 = createMockProcess() - const p2 = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => { - if (isIgnoredRgPass(args)) { - return p2 - } - return p1 - }) + spawnMock.mockReturnValue(p1) const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.cache/data.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.stably/config.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n') + p1.stdout?.emit('data', '.next/cache/1.js\0') + p1.stdout?.emit('data', '.cache/data.json\0') + p1.stdout?.emit('data', '.stably/config.json\0') + p1.stdout?.emit('data', '.vscode/settings.json\0') + p1.stdout?.emit('data', '.idea/workspace.xml\0') + p1.stdout?.emit('data', 'valid.ts\0') p1.emit('close', 0, null) - - // Empty ignored result - p2.emit('close', 0, null) }, 10) const result = await promise @@ -451,7 +443,7 @@ describe('filesystem-list-files', () => { }) describe('when the bundled rg cannot start', () => { - it('kills only the admitted pass when ignored rg fails before spawn', async () => { + it('does not kill a process that failed before receiving a pid', async () => { const primary = createMockProcess() const missingIgnored = createMockProcess() Object.defineProperty(missingIgnored, 'pid', { value: undefined }) @@ -461,11 +453,11 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) await flushMicrotasks() - expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) missingIgnored.emit('close', -2, null) await expect(promise).rejects.toThrow(BUNDLED_ERROR) - expect(primary.kill).toHaveBeenCalled() + expect(primary.kill).not.toHaveBeenCalled() expect(missingIgnored.kill).not.toHaveBeenCalled() const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) expect(() => missingIgnored.emit('error', error)).not.toThrow() diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index f1e8fb56334..9d9042584d9 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -1,3 +1,5 @@ +import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode' +import { RipgrepFilenameDecoder, RipgrepFilenameError } from '../../shared/ripgrep-filename-decoder' import { sep } from 'node:path' import type { ChildProcess } from 'node:child_process' import type { Store } from '../persistence' @@ -8,7 +10,6 @@ import { buildExcludePathPrefixes, buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, - type RgOutputMode, shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' @@ -79,6 +80,10 @@ export async function listQuickOpenFiles( const runRg = (args: string[]): Promise => { return new Promise((resolve, reject) => { + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + finish(error) + }, Boolean(wslDistroForOutput)) let buf = '' let done = false let parseablePathCount = 0 @@ -138,18 +143,22 @@ export async function listQuickOpenFiles( return } let timer: ReturnType - const handleStdoutData = (chunk: string): void => { - buf += chunk + const handleStdoutData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + buf += decoded let start = 0 - let newlineIdx = buf.indexOf('\n', start) - while (newlineIdx !== -1) { - if (processLine(buf.substring(start, newlineIdx))) { + let delimiterIdx = buf.indexOf('\0', start) + while (delimiterIdx !== -1) { + if (processLine(buf.substring(start, delimiterIdx))) { buf = '' finishAtLimit() return } - start = newlineIdx + 1 - newlineIdx = buf.indexOf('\n', start) + start = delimiterIdx + 1 + delimiterIdx = buf.indexOf('\0', start) } buf = start < buf.length ? buf.substring(start) : '' } @@ -211,14 +220,15 @@ export async function listQuickOpenFiles( finish(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } if (buf && processLine(buf)) { buf = '' finishAtLimit() return } - if (code === 0 || code === 1) { - finish() - } else if (code === 2 && parseablePathCount > 0) { + if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) { // rg can return 2 for unreadable subdirectories while still listing // usable files from the rest of the root. finish() @@ -257,7 +267,6 @@ export async function listQuickOpenFiles( children.push({ child, isDone: () => done, finish }) - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) @@ -277,9 +286,7 @@ export async function listQuickOpenFiles( } const killSurvivors = (): void => { - // Why: if one rg pass fails, Promise.all rejects immediately while the - // sibling scan can keep walking a huge tree until timeout. Stop it so - // repeated Quick Open attempts do not accumulate local rg processes. + // Failed listings must release any process still walking the tree. for (const entry of children) { if (entry.isDone()) { continue @@ -292,34 +299,29 @@ export async function listQuickOpenFiles( } function finishAtLimit(): void { - for (const entry of children) { - if (entry.isDone()) { - continue - } - entry.finish() - if (entry.child.exitCode === null && entry.child.signalCode === null) { - killSpawnedRipgrepProcess(entry.child) - } - } + killSurvivors() } + try { - const primaryRun = runRg(primary) if (maxResults === undefined && maxSerializedBytes === undefined) { - // Why: a pid-less primary proves launch failure; avoid doubling the failed spawn. - await (children[0]?.child.pid === undefined - ? primaryRun - : Promise.all([primaryRun, runRg(ignoredPass)])) + // The broader pass already includes source files; an unbounded listing needs only one scan. + await runRg(ignoredPass) } else { // Why: ignored-file output can be much larger and faster than the primary pass; let source // files claim every bounded autocomplete budget first, including the transport byte cap. - await primaryRun + await runRg(primary) if ( (maxResults === undefined || files.size < maxResults) && (maxSerializedBytes === undefined || serializedBytes < maxSerializedBytes) ) { // Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches. await runRg(ignoredPass).catch((err: unknown) => { - if (!pathFilter || signal?.aborted || err instanceof RipgrepUnavailableError) { + if ( + !pathFilter || + signal?.aborted || + err instanceof RipgrepUnavailableError || + err instanceof RipgrepFilenameError + ) { throw err } }) @@ -334,19 +336,3 @@ export async function listQuickOpenFiles( ? result : limitQuickOpenFilesBySerializedBytes(result, maxSerializedBytes) } - -function getQuickOpenRgOutputMode( - rawLine: string, - translatedLine: string, - rootPath: string -): RgOutputMode { - if ( - translatedLine !== rawLine || - rawLine.startsWith('/') || - /^[A-Za-z]:[\\/]/.test(rawLine) || - rawLine.startsWith('\\\\') - ) { - return { kind: 'absolute', rootPath } - } - return { kind: 'cwd-relative' } -} diff --git a/src/main/ipc/filesystem-search-file-paths.test.ts b/src/main/ipc/filesystem-search-file-paths.test.ts index e9415d66431..57c817c3b40 100644 --- a/src/main/ipc/filesystem-search-file-paths.test.ts +++ b/src/main/ipc/filesystem-search-file-paths.test.ts @@ -81,6 +81,64 @@ describe('searchQuickOpenFilePaths', () => { ) }) + it('preserves cancellation while an incomplete UTF-8 scalar is buffered', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const controller = new AbortController() + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'file', + limit: 2, + signal: controller.signal + }) + await flushMicrotasks() + child.stdout?.emit('data', Buffer.from([0xf0, 0x9f])) + controller.abort() + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + }) + + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { query: 'file', limit: 2 }) + await flushMicrotasks() + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + + it('preserves control characters within ranked paths', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'target', + limit: 2 + }) + await flushMicrotasks() + child.stdout?.emit('data', 'first\ntarget.ts\0target.ts\r\0') + child.emit('close', 0, null) + expect((await promise).paths.sort()).toEqual(['first\ntarget.ts', 'target.ts\r'].sort()) + }) + + it('rejects and stops an oversized path rather than ranking a truncated suffix', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'target', + limit: 2 + }) + await flushMicrotasks() + child.stdout?.emit('data', 'x'.repeat(64 * 1024 + 1)) + await expect(promise).rejects.toThrow('file path exceeds the listing limit') + expect(child.kill).toHaveBeenCalledTimes(1) + child.stdout?.emit('data', 'target.ts\0') + child.emit('close', 0, null) + }) + it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) @@ -94,14 +152,11 @@ describe('searchQuickOpenFilePaths', () => { expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1) expect(wslAwareSpawnMock.mock.calls[0][0]).toBe('/bundled/rg') expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') - ;(child.stdout as unknown as EventEmitter).emit( + child.stdout?.emit( 'data', - `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n` - ) - ;(child.stdout as unknown as EventEmitter).emit( - 'data', - 'nested/src/sta-4354-target.ts\nsrc/sta-4354-target.ts\n' + `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0` ) + child.stdout?.emit('data', 'nested/src/sta-4354-target.ts\0src/sta-4354-target.ts\0') child.emit('close', 0, null) await expect(promise).resolves.toEqual({ @@ -138,7 +193,7 @@ describe('searchQuickOpenFilePaths', () => { limit: 32 }) await flushMicrotasks() - ;(child.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + child.stdout?.emit('data', 'src/target.ts\0') child.emit('close', 0, null) await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] }) @@ -164,10 +219,7 @@ describe('searchQuickOpenFilePaths', () => { await flushMicrotasks() expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2) - ;(succeeded.stdout as unknown as EventEmitter).emit( - 'data', - 'data/chunk-077568/sta-4354-gitignored-target.bin\n' - ) + succeeded.stdout?.emit('data', 'data/chunk-077568/sta-4354-gitignored-target.bin\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual({ @@ -189,7 +241,7 @@ describe('searchQuickOpenFilePaths', () => { limit: 32 }) await flushMicrotasks() - ;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + succeeded.stdout?.emit('data', 'src/target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] }) diff --git a/src/main/ipc/filesystem-search-file-paths.ts b/src/main/ipc/filesystem-search-file-paths.ts index 4a003aa17c5..3de6f4c6342 100644 --- a/src/main/ipc/filesystem-search-file-paths.ts +++ b/src/main/ipc/filesystem-search-file-paths.ts @@ -1,3 +1,5 @@ +import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode' +import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder' import { sep } from 'node:path' import type { Store } from '../persistence' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' @@ -6,8 +8,7 @@ import { buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, shouldExcludeQuickOpenRelPath, - shouldIncludeQuickOpenPath, - type RgOutputMode + shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search' import { @@ -110,7 +111,11 @@ function scanRipgrepPaths(args: { return Promise.reject(fileListingCancellationError(args.signal)) } return new Promise((resolve, reject) => { - const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0x0a) + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + finish(error) + }, Boolean(args.wslDistroForOutput)) + const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0) let done = false let parseablePathCount = 0 let processErrorObserved = false @@ -141,7 +146,7 @@ function scanRipgrepPaths(args: { : rawLine const relPath = normalizeQuickOpenRgLine( translated, - getOutputMode(rawLine, translated, args.authorizedRootPath) + getQuickOpenRgOutputMode(rawLine, translated, args.authorizedRootPath) ) if (relPath === null) { return @@ -178,11 +183,19 @@ function scanRipgrepPaths(args: { resolve() } } - const handleStdoutData = (chunk: string): void => { - pathAccumulator.push(chunk, (path) => { + const handleStdoutData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + const result = pathAccumulator.push(decoded, (path) => { processLine(path) return true }) + if (result === 'path-too-large') { + killSpawnedRipgrepProcess(child) + finish(new Error('Quick Open file path exceeds the listing limit')) + } } const handleStderrData = (): void => { /* drain */ @@ -233,6 +246,9 @@ function scanRipgrepPaths(args: { finish(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } const trailingPath = pathAccumulator.finish() if (trailingPath) { processLine(trailingPath) @@ -249,7 +265,6 @@ function scanRipgrepPaths(args: { finish(fileListingCancellationError(args.signal)) } - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) @@ -265,12 +280,3 @@ function scanRipgrepPaths(args: { } }) } - -function getOutputMode(rawLine: string, translatedLine: string, rootPath: string): RgOutputMode { - return translatedLine !== rawLine || - rawLine.startsWith('/') || - /^[A-Za-z]:[\\/]/.test(rawLine) || - rawLine.startsWith('\\\\') - ? { kind: 'absolute', rootPath } - : { kind: 'cwd-relative' } -} diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 9c37bacb20a..d4096500381 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -199,7 +199,7 @@ describe('filesystem rg search timeout', () => { } ) - it('keeps post-spawn errors on the existing empty-result path', async () => { + it('rejects post-spawn errors instead of returning an empty result', async () => { const child = createMockProcess() Object.defineProperty(child, 'pid', { value: 1 }) wslAwareSpawnMock.mockReturnValue(child) @@ -212,7 +212,7 @@ describe('filesystem rg search timeout', () => { await flushMicrotasks() child.emit('error', new Error('post-spawn failure')) - await expect(promise).resolves.toMatchObject({ files: [] }) + await expect(promise).rejects.toThrow('post-spawn failure') }) // Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler @@ -298,6 +298,34 @@ describe('filesystem rg search timeout', () => { expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/linux/rg') }) + it('marks WSL filenames that UNC cannot represent as incomplete', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all store access is mocked for this handler. + registerFilesystemHandlers({} as never) + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: 'C:\\repo', query: 'hello' } + ) + await flushMicrotasks() + child.stdout?.emit( + 'data', + `${JSON.stringify({ + type: 'match', + data: { + path: { text: './a\\b.txt' }, + lines: { text: 'hello\n' }, + line_number: 1, + submatches: [{ start: 0, end: 5 }] + } + })}\n` + ) + child.emit('close', 0, null) + await expect(promise).resolves.toMatchObject({ files: [], truncated: true }) + expect(toWindowsWslPathMock).not.toHaveBeenCalled() + }) + it('translates WSL rg output for Windows-path project search results', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index 9212200e151..94cc07a2f1f 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -1,3 +1,4 @@ +import { RipgrepSearchDiagnostics } from '../../../shared/ripgrep-search-diagnostics' import { SearchSubprocessLineAccumulator } from '../../../shared/search-subprocess-lines' import { ipcMain } from 'electron' import type { ChildProcess } from 'node:child_process' @@ -65,7 +66,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro return new Promise((resolvePromise, rejectPromise) => { - const rgArgs = buildRgArgs(args.query, rootPath, args) + const rgArgs = buildRgArgs(args.query, '.', args) // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. const previousChild = activeTextSearches.get(searchKey) if (previousChild) { @@ -73,7 +74,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte } const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false @@ -81,8 +83,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte let killTimeout: ReturnType const transformAbsPath = wslDistroForOutput - ? (path: string): string => - path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path + ? (path: string): string | null => + path.includes('\\') + ? null + : path.startsWith('/') + ? toWindowsWslPath(path, wslDistroForOutput) + : path : undefined const finish = (result: SearchResult | PromiseLike): void => { @@ -108,7 +114,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte } resolvePromise(result) } - const resolveOnce = (): void => finish(finalize(acc)) + const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => { + const error = diagnostics.failure(code, signal, acc) + finish(error ? Promise.reject(error) : finalize(acc)) + } const rejectUnavailable = (): void => finish(Promise.reject(bundledRipgrepUnavailableError())) const processLine = (line: string): void => { @@ -138,10 +147,16 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte activeTextSearches.set(searchKey, nextChild) const handleStdoutData = (chunk: string): void => { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + } } - const handleStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. + const handleStderrData = (chunk: Buffer): void => { + diagnostics.append(chunk) } const handleError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true @@ -151,18 +166,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte return } if (child && isRipgrepUnavailableExit(child, null, null)) { - // Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the - // binary for it tells the user to reinstall Orca over a workspace that simply moved. - // Why detach close first: a failed spawn emits error THEN close(code < 0), and - // close settles synchronously, so this probe would otherwise race it on a sub-ms - // margin -- two measurements disagreed on which wins. Detaching makes it deterministic. + // Distinguish a missing workspace from a missing binary before close can settle. child.off('close', handleClose) - // Why catch: a failed probe must not strand the search; fall back to the prior verdict. void isRipgrepSpawnCwdUsable(rootPath) .catch(() => true) .then((usable) => { - // Why re-check: finish() drops its argument once settled, so a rejected promise - // built after the close handler already won would go unhandled. + // A late rejected promise must not escape after close settles the search. if (resolved) { return } @@ -174,7 +183,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte }) return } - resolveOnce() + finish(Promise.reject(error)) + if (child) { + killSpawnedRipgrepProcess(child) + } } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise @@ -193,11 +205,11 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte rejectUnavailable() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } nextChild.stdout?.setEncoding('utf-8') diff --git a/src/main/ipc/github-issue-mutation-handlers.ts b/src/main/ipc/github-issue-mutation-handlers.ts index 992f0c6e11f..8936e2eb15a 100644 --- a/src/main/ipc/github-issue-mutation-handlers.ts +++ b/src/main/ipc/github-issue-mutation-handlers.ts @@ -15,7 +15,14 @@ import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-eve export function registerGitHubIssueMutationHandlers(store: Store): void { ipcMain.handle( 'gh:updateIssue', - async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { + async ( + event, + args: GitHubRepoScopedArgs & { + number: number + updates: GitHubIssueUpdate + ownerRepo?: GitHubOwnerRepo + } + ) => { const repo = assertRegisteredGitHubRepo(args, store) if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { return { ok: false, error: 'Invalid issue number' } @@ -28,7 +35,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void { args.number, args.updates, getGitHubRepoConnectionId(repo), - ...getGitHubLocalGitOptionArgs(store, repo) + getGitHubLocalGitOptionArgs(store, repo)[0], + repo.issueSourcePreference, + ...(args.ownerRepo ? [args.ownerRepo] : []) ) if (result.ok) { broadcastGitHubWorkItemMutation( @@ -67,7 +76,9 @@ export function registerGitHubIssueMutationHandlers(store: Store): void { args.body.trim(), getGitHubRepoConnectionId(repo), args.prRepo ?? null, - ...getGitHubLocalGitOptionArgs(store, repo) + getGitHubLocalGitOptionArgs(store, repo)[0], + // Why: the issue source selector only scopes issues; PR comments keep their resolution. + args.type === 'pr' ? undefined : repo.issueSourcePreference ) if (result.ok) { broadcastGitHubWorkItemMutation( @@ -84,23 +95,46 @@ export function registerGitHubIssueMutationHandlers(store: Store): void { } ) - ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => { - const repo = assertRegisteredGitHubRepo(args, store) - return listLabels( - repo.path, - repo.issueSourcePreference, - getGitHubRepoConnectionId(repo), - ...getGitHubLocalGitOptionArgs(store, repo) - ) - }) + ipcMain.handle( + 'gh:listLabels', + (_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo) + const connectionId = getGitHubRepoConnectionId(repo) + if (args.ownerRepo) { + return listLabels( + repo.path, + repo.issueSourcePreference, + connectionId, + localGitOptions[0], + args.ownerRepo + ) + } + return listLabels(repo.path, repo.issueSourcePreference, connectionId, ...localGitOptions) + } + ) - ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => { - const repo = assertRegisteredGitHubRepo(args, store) - return listAssignableUsers( - repo.path, - repo.issueSourcePreference, - getGitHubRepoConnectionId(repo), - ...getGitHubLocalGitOptionArgs(store, repo) - ) - }) + ipcMain.handle( + 'gh:listAssignableUsers', + (_event, args: GitHubRepoScopedArgs & { ownerRepo?: GitHubOwnerRepo }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo) + const connectionId = getGitHubRepoConnectionId(repo) + if (args.ownerRepo) { + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + connectionId, + localGitOptions[0], + args.ownerRepo + ) + } + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + connectionId, + ...localGitOptions + ) + } + ) } diff --git a/src/main/ipc/github-issue-source-preference.test.ts b/src/main/ipc/github-issue-source-preference.test.ts index ffef0da244b..eb51af7773e 100644 --- a/src/main/ipc/github-issue-source-preference.test.ts +++ b/src/main/ipc/github-issue-source-preference.test.ts @@ -143,4 +143,124 @@ describe('registerGitHubHandlers', () => { 'origin' ) }) + + it('passes the displayed repository ahead of the current source preference', async () => { + harness.repos[0].issueSourcePreference = 'upstream' + const ownerRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } + mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' }) + getWorkItemDetailsMock.mockResolvedValue(null) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered. + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:workItemDetails'](null, { + repoPath: '/workspace/repo', + number: 12, + type: 'issue', + ownerRepo + }) + await handlers['gh:updateIssue'](null, { + repoPath: '/workspace/repo', + number: 12, + updates: { body: 'Origin edit' }, + ownerRepo + }) + await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo', ownerRepo }) + await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo', ownerRepo }) + + expect(getWorkItemDetailsMock).toHaveBeenCalledWith( + '/workspace/repo', + 12, + 'issue', + null, + undefined, + 'upstream', + ownerRepo + ) + expect(mocks.client.updateIssue).toHaveBeenCalledWith( + '/workspace/repo', + 12, + { body: 'Origin edit' }, + null, + undefined, + 'upstream', + ownerRepo + ) + expect(mocks.client.listLabels).toHaveBeenCalledWith( + '/workspace/repo', + 'upstream', + null, + undefined, + ownerRepo + ) + expect(mocks.client.listAssignableUsers).toHaveBeenCalledWith( + '/workspace/repo', + 'upstream', + null, + undefined, + ownerRepo + ) + }) + + it('pins the repo origin source preference on issue update and comment IPC', async () => { + harness.repos = [ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + issueSourcePreference: 'origin' + } + ] + mocks.client.updateIssue.mockResolvedValue({ ok: false, error: 'stub' }) + mocks.client.addIssueComment.mockResolvedValue({ ok: false, error: 'stub' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These fixtures implement the repo methods used by the handlers; unrelated handlers are only registered. + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:updateIssue'](null, { + repoPath: '/workspace/repo', + number: 5, + updates: { body: 'Updated' } + }) + await handlers['gh:addIssueComment'](null, { + repoPath: '/workspace/repo', + number: 5, + body: 'Issue comment' + }) + await handlers['gh:addIssueComment'](null, { + repoPath: '/workspace/repo', + number: 6, + body: 'PR comment', + type: 'pr' + }) + + expect(mocks.client.updateIssue).toHaveBeenCalledWith( + '/workspace/repo', + 5, + { body: 'Updated' }, + null, + undefined, + 'origin' + ) + expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith( + 1, + '/workspace/repo', + 5, + 'Issue comment', + null, + null, + undefined, + 'origin' + ) + expect(mocks.client.addIssueComment).toHaveBeenNthCalledWith( + 2, + '/workspace/repo', + 6, + 'PR comment', + null, + null, + undefined, + undefined + ) + }) }) diff --git a/src/main/ipc/github-work-item-handlers.ts b/src/main/ipc/github-work-item-handlers.ts index 26c21b3db81..94e982d60dc 100644 --- a/src/main/ipc/github-work-item-handlers.ts +++ b/src/main/ipc/github-work-item-handlers.ts @@ -146,15 +146,20 @@ export function registerGitHubWorkItemHandlers(store: Store): void { } ) - ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { - const repo = assertRegisteredGitHubRepo(args, store) - return dispatchWorkItem( - args, - repo, - getWorkItemDetails, - getGitHubLocalGitOptionArgs(store, repo)[0] - ) - }) + ipcMain.handle( + 'gh:workItemDetails', + (_event, args: WorkItemArgs & { ownerRepo?: GitHubOwnerRepo }) => { + const repo = assertRegisteredGitHubRepo(args, store) + return dispatchWorkItem( + args, + repo, + args.ownerRepo + ? (...params) => getWorkItemDetails(...params, args.ownerRepo) + : getWorkItemDetails, + getGitHubLocalGitOptionArgs(store, repo)[0] + ) + } + ) ipcMain.handle( 'gh:notifyWorkItemMutated', diff --git a/src/main/ipc/github-wsl-runtime-routing.test.ts b/src/main/ipc/github-wsl-runtime-routing.test.ts index e2f30c18eba..977422cb68e 100644 --- a/src/main/ipc/github-wsl-runtime-routing.test.ts +++ b/src/main/ipc/github-wsl-runtime-routing.test.ts @@ -138,6 +138,15 @@ describe('registerGitHubHandlers', () => { ) await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo' }) await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo' }) + const metadataRepo = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } + await handlers['gh:listLabels'](null, { + repoPath: '/workspace/repo', + ownerRepo: metadataRepo + }) + await handlers['gh:listAssignableUsers'](null, { + repoPath: '/workspace/repo', + ownerRepo: metadataRepo + }) expect(getPRForBranchMock).toHaveBeenCalledWith( '/workspace/repo', @@ -197,7 +206,8 @@ describe('registerGitHubHandlers', () => { 7, { body: 'Updated' }, null, - localGitOptions + localGitOptions, + undefined ) expect(addIssueCommentMock).toHaveBeenCalledWith( '/workspace/repo', @@ -205,9 +215,24 @@ describe('registerGitHubHandlers', () => { 'Comment', null, null, - localGitOptions + localGitOptions, + undefined ) expect(listLabelsMock).toHaveBeenCalledWith('/workspace/repo', undefined, null, localGitOptions) + expect(listLabelsMock).toHaveBeenLastCalledWith( + '/workspace/repo', + undefined, + null, + localGitOptions, + metadataRepo + ) + expect(listAssignableUsersMock).toHaveBeenLastCalledWith( + '/workspace/repo', + undefined, + null, + localGitOptions, + metadataRepo + ) expect(listAssignableUsersMock).toHaveBeenCalledWith( '/workspace/repo', undefined, diff --git a/src/main/ipc/markdown-documents-remote-paths.test.ts b/src/main/ipc/markdown-documents-remote-paths.test.ts new file mode 100644 index 00000000000..0d5dc66983a --- /dev/null +++ b/src/main/ipc/markdown-documents-remote-paths.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as NodePath from 'node:path' + +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, extname: actual.win32.extname } +}) + +import { isMarkdownDocumentName, markdownDocumentFromRelativePath } from './markdown-documents' + +describe('remote Markdown filenames on a Windows client', () => { + it('keeps the local filename helper on Windows semantics', () => { + expect(isMarkdownDocumentName('notes\\.md')).toBe(false) + expect(isMarkdownDocumentName('notes.md\\')).toBe(true) + }) + + it.each(['notes\\.md', 'a\\b.MDX', '..\\a.markdown', 'nested/README.md'])( + 'preserves POSIX filename %s and its extension', + (relativePath) => { + const basename = relativePath.slice(relativePath.lastIndexOf('/') + 1) + expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toEqual({ + filePath: `/home/repo/${relativePath}`, + relativePath, + basename, + name: basename.slice(0, basename.lastIndexOf('.')) + }) + } + ) + + it.each(['notes.md\\', 'nested/.md', '../outside.md'])( + 'rejects non-Markdown or escaping POSIX filename %s', + (relativePath) => { + expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toBeNull() + } + ) + + it('normalizes Windows remote separators before reading the basename', () => { + expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\README.MD')).toEqual({ + filePath: 'C:\\repo/notes/README.MD', + relativePath: 'notes/README.MD', + basename: 'README.MD', + name: 'README' + }) + expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\.md')).toBeNull() + expect(markdownDocumentFromRelativePath('C:\\repo', '..\\outside.md')).toBeNull() + }) +}) diff --git a/src/main/ipc/markdown-documents-ripgrep.test.ts b/src/main/ipc/markdown-documents-ripgrep.test.ts index daee482bae1..13f48b78e17 100644 --- a/src/main/ipc/markdown-documents-ripgrep.test.ts +++ b/src/main/ipc/markdown-documents-ripgrep.test.ts @@ -47,6 +47,24 @@ describe('Markdown document ripgrep lifecycle', () => { expect(child.listenerCount('close')).toBe(0) }) + it('preserves timeout when an incomplete UTF-8 scalar is abandoned', async () => { + vi.useFakeTimers() + const result = listMarkdownDocuments(root) + const outcome = expect(result).rejects.toThrow('timed out') + child.stdout.write(Buffer.from([0xf0, 0x9f])) + await vi.advanceTimersByTimeAsync(15_000) + await outcome + }) + + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const result = listMarkdownDocuments(root) + child.stdout.write(Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(result).rejects.toThrow('not valid UTF-8') + }) + it('accepts an empty listing', async () => { const result = listMarkdownDocuments(root) child.emit('close', 1, null) diff --git a/src/main/ipc/markdown-documents.test.ts b/src/main/ipc/markdown-documents.test.ts index b23f5748cb4..698935b1f62 100644 --- a/src/main/ipc/markdown-documents.test.ts +++ b/src/main/ipc/markdown-documents.test.ts @@ -1,7 +1,41 @@ import { describe, expect, it } from 'vitest' -import { markdownDocumentFromFilePath } from './markdown-documents' +import { + markdownDocumentFromFilePath, + markdownDocumentFromRelativePath +} from './markdown-documents' describe('markdownDocumentFromFilePath', () => { + it.skipIf(process.platform === 'win32')('preserves local literal backslash names', () => { + expect(markdownDocumentFromFilePath('/repo\\', '/repo\\/a\\b.md')).toMatchObject({ + filePath: '/repo\\/a\\b.md', + relativePath: 'a\\b.md', + basename: 'a\\b.md' + }) + }) + + it('preserves POSIX remote filenames and trailing root backslashes', () => { + for (const name of ['a\\b.md', 'a/b.md', '..\\a.md']) { + expect(markdownDocumentFromRelativePath('/repo\\', name)).toMatchObject({ + filePath: `/repo\\/${name}`, + relativePath: name, + basename: name.slice(name.lastIndexOf('/') + 1) + }) + } + expect(markdownDocumentFromRelativePath('/repo\\', '../a.md')).toBeNull() + }) + + it.each(['C:\\repo\\', '\\\\server\\share\\repo\\'])( + 'preserves Windows separator handling under %s', + (root) => { + expect(markdownDocumentFromRelativePath(root, 'a\\b.md')).toMatchObject({ + filePath: `${root.slice(0, -1)}/a/b.md`, + relativePath: 'a/b.md', + basename: 'b.md' + }) + expect(markdownDocumentFromRelativePath(root, '..\\a.md')).toBeNull() + } + ) + it('keeps in-root path segments that merely start with parent traversal text', () => { expect(markdownDocumentFromFilePath('/workspace', '/workspace/..notes/file.md')).toMatchObject({ filePath: '/workspace/..notes/file.md', diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts index 6695c7aa89c..1f457dba97e 100644 --- a/src/main/ipc/markdown-documents.ts +++ b/src/main/ipc/markdown-documents.ts @@ -1,4 +1,15 @@ -import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path' +import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { normalizeRelativePath } from '../../shared/text-search-paths' +import { + basename as pathBasename, + extname, + isAbsolute, + join, + posix, + relative, + resolve +} from 'node:path' import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types' import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn' import { parseWslPath } from '../wsl' @@ -7,36 +18,34 @@ import { ripgrepMissingCwdError } from '../../shared/ripgrep-process-availability' -function normalizeRelativePath(path: string): string { - return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +export function isMarkdownDocumentName(name: string): boolean { + return isMarkdownExtension(extname(name)) } -export function isMarkdownDocumentName(name: string): boolean { - const extension = extname(name).toLowerCase() - return extension === '.md' || extension === '.mdx' || extension === '.markdown' +function isMarkdownExtension(extension: string): boolean { + const normalized = extension.toLowerCase() + return normalized === '.md' || normalized === '.mdx' || normalized === '.markdown' } function basenameFromRelativePath(relativePath: string): string { - const normalizedPath = relativePath.replaceAll('\\', '/') - return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1) + return relativePath.slice(relativePath.lastIndexOf('/') + 1) } function isSafeRelativePath(relativePath: string): boolean { return !relativePath.split('/').includes('..') } -function hasParentTraversalSegment(relativePath: string): boolean { - return relativePath.split(/[\\/]+/).includes('..') -} - function rootRelativePath(rootPath: string, filePath: string): string | null { const resolvedRoot = resolve(rootPath) const resolvedFile = resolve(filePath) const relativePath = relative(resolvedRoot, resolvedFile) - if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) { + if ( + !isSafeRelativePath(normalizeRelativePath(relativePath, rootPath)) || + isAbsolute(relativePath) + ) { return null } - return normalizeRelativePath(relativePath) + return normalizeRelativePath(relativePath, rootPath) } export function fileDocumentFromFilePath( @@ -50,7 +59,7 @@ export function fileDocumentFromFilePath( rootRelativePath(rootPath, filePath) ?? (options.outsideRootRelativePath === 'basename' ? basename - : normalizeRelativePath(relative(rootPath, filePath))) + : normalizeRelativePath(relative(rootPath, filePath), rootPath)) return { filePath, relativePath, @@ -65,18 +74,22 @@ export function markdownDocumentFromRelativePath( rootPath: string, relativePath: string ): MarkdownDocument | null { - const normalizedRelativePath = normalizeRelativePath(relativePath) + const normalizedRelativePath = normalizeRelativePath(relativePath, rootPath) // Why: SSH providers should return root-relative paths; reject escape // segments before building a synthetic absolute path for renderer use. if (!isSafeRelativePath(normalizedRelativePath)) { return null } const basename = basenameFromRelativePath(normalizedRelativePath) - if (!isMarkdownDocumentName(basename)) { + // Remote separators are already normalized; a POSIX backslash stays part of the name. + const extension = posix.extname(basename) + if (!isMarkdownExtension(extension)) { return null } - const extension = extname(basename) - const normalizedRoot = rootPath.replace(/[\\/]+$/, '') + const normalizedRoot = rootPath.replace( + isWindowsAbsolutePathLike(rootPath) ? /[\\/]+$/ : /\/+$/, + '' + ) return { filePath: `${normalizedRoot}/${normalizedRelativePath}`, relativePath: normalizedRelativePath, @@ -111,9 +124,9 @@ export async function listMarkdownDocuments( '--null', '--path-separator', '/', - // Directory-only globs preserve hidden Markdown files without traversing hidden folders. + // Keep case variants in --glob: --iglob is applied after exclusions and can reopen hidden folders. '--glob', - '**', + '*.{[mM][dD],[mM][dD][xX],[mM][aA][rR][kK][dD][oO][wW][nN]}', '--glob', '!**/.*/', '--glob', @@ -131,6 +144,10 @@ export async function listMarkdownDocuments( ) return new Promise((resolveListing, reject) => { + const filenameDecoder = new RipgrepFilenameDecoder( + (error) => finish(error), + Boolean(parseWslPath(rootPath)?.distro ?? options.wslDistro) + ) const documents: MarkdownDocument[] = [] let carry = '' let stderr = '' @@ -172,8 +189,12 @@ export async function listMarkdownDocuments( const onStderr = (chunk: string): void => { stderr = (stderr + chunk).slice(0, 4096) } - const onData = (chunk: string): void => { - carry += chunk + const onData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + carry += decoded let start = 0 let end: number while ((end = carry.indexOf('\0', start)) !== -1) { @@ -201,10 +222,11 @@ export async function listMarkdownDocuments( finish(ripgrepMissingCwdError(rootPath)) } else if (signal || (code !== 0 && code !== 1)) { finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`)) - } else if (carry) { - finish(new Error('Incomplete path in Markdown document listing')) } else { - finish() + if (!filenameDecoder.finish()) { + return + } + finish(carry ? new Error('Incomplete path in Markdown document listing') : undefined) } } const timer = setTimeout( @@ -212,7 +234,6 @@ export async function listMarkdownDocuments( MARKDOWN_LISTING_TIMEOUT_MS ) timer.unref?.() - child.stdout?.setEncoding('utf8') child.stderr?.setEncoding('utf8') child.stdout?.on('data', onData) child.stderr?.on('data', onStderr) diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index 37a061e0541..ec13423fcb6 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as LocalCommandResolver from './command-path-resolver' const { handleMock, @@ -12,6 +13,7 @@ const { getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + listLocalCommandPathsMock, mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock } = vi.hoisted(() => ({ @@ -26,6 +28,7 @@ const { getGiteaAuthStatusMock: vi.fn(), resolveCliCommandsMock: vi.fn(), isCommandOnLocalPathMock: vi.fn(), + listLocalCommandPathsMock: vi.fn(), mergePersistedWindowsPathAsyncMock: vi.fn(), mergePersistedWindowsPathMock: vi.fn() })) @@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({ // Why (#9297): local PATH resolution is now fs-based (no where/which spawn). // These tests express "which commands are on PATH" via the where/which mock, // so route the resolver through that same mock to preserve their intent. -vi.mock('./command-path-resolver', () => ({ - isCommandOnLocalPath: isCommandOnLocalPathMock +vi.mock('./command-path-resolver', async (importOriginal) => ({ + ...(await importOriginal()), + isCommandOnLocalPath: isCommandOnLocalPathMock, + listLocalCommandPaths: listLocalCommandPathsMock })) vi.mock('../pty/windows-environment-path', () => ({ @@ -113,6 +118,7 @@ describe('preflight', () => { getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + listLocalCommandPathsMock, mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock }, diff --git a/src/main/ipc/preflight-agent-refresh.test.ts b/src/main/ipc/preflight-agent-refresh.test.ts index cc64e2beeca..d4159c3f7b2 100644 --- a/src/main/ipc/preflight-agent-refresh.test.ts +++ b/src/main/ipc/preflight-agent-refresh.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as LocalCommandResolver from './command-path-resolver' const { handleMock, @@ -12,6 +13,7 @@ const { getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + listLocalCommandPathsMock, mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock } = vi.hoisted(() => ({ @@ -26,6 +28,7 @@ const { getGiteaAuthStatusMock: vi.fn(), resolveCliCommandsMock: vi.fn(), isCommandOnLocalPathMock: vi.fn(), + listLocalCommandPathsMock: vi.fn(), mergePersistedWindowsPathAsyncMock: vi.fn(), mergePersistedWindowsPathMock: vi.fn() })) @@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({ // Why (#9297): local PATH resolution is now fs-based (no where/which spawn). // These tests express "which commands are on PATH" via the where/which mock, // so route the resolver through that same mock to preserve their intent. -vi.mock('./command-path-resolver', () => ({ - isCommandOnLocalPath: isCommandOnLocalPathMock +vi.mock('./command-path-resolver', async (importOriginal) => ({ + ...(await importOriginal()), + isCommandOnLocalPath: isCommandOnLocalPathMock, + listLocalCommandPaths: listLocalCommandPathsMock })) vi.mock('../pty/windows-environment-path', () => ({ @@ -109,6 +114,7 @@ describe('preflight', () => { getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + listLocalCommandPathsMock, mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock }, diff --git a/src/main/ipc/preflight-command-exec.test.ts b/src/main/ipc/preflight-command-exec.test.ts index 63c2b35aceb..5e571b18613 100644 --- a/src/main/ipc/preflight-command-exec.test.ts +++ b/src/main/ipc/preflight-command-exec.test.ts @@ -1,15 +1,44 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import path from 'node:path' +import type * as LocalCommandResolver from './command-path-resolver' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' -const { runPreflightCommandInWslMock } = vi.hoisted(() => ({ - runPreflightCommandInWslMock: vi.fn() +const { + runPreflightCommandInWslMock, + execFileAsyncMock, + listLocalCommandPathsMock, + runProcessMock +} = vi.hoisted(() => ({ + runPreflightCommandInWslMock: vi.fn(), + execFileAsyncMock: vi.fn(), + listLocalCommandPathsMock: vi.fn(), + runProcessMock: vi.fn() })) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) + +vi.mock('./preflight-local-env', () => ({ buildLocalPreflightEnv: () => undefined })) + vi.mock('./preflight-wsl-command', () => ({ runPreflightCommandInWsl: runPreflightCommandInWslMock })) -import { isCommandOnPath } from './preflight-command-exec' +vi.mock('./command-path-resolver', async (importOriginal) => ({ + ...(await importOriginal()), + isCommandOnLocalPath: vi.fn(async () => false), + listLocalCommandPaths: listLocalCommandPathsMock +})) + +// Why: `findRunnableLocalCommand` decides what to spawn next from the error +// `execFile` puts on a failed probe, so the shapes below have to be its own. +vi.mock('child_process', () => { + const execFileWithPromisify = Object.assign(vi.fn(), { + [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock + }) + return { execFile: execFileWithPromisify, spawn: vi.fn() } +}) + +import { findRunnableLocalCommand, isCommandOnPath } from './preflight-command-exec' describe('isCommandOnPath', () => { const sentinel = '__ORCA_PREFLIGHT_COMMAND_PATH__' @@ -59,3 +88,222 @@ describe('isCommandOnPath', () => { await expect(isCommandOnPath('codex', { distro: 'Ubuntu' })).resolves.toBe(expected) }) }) + +describe('findRunnableLocalCommand', () => { + const shim = '/Users/tester/.asdf/shims/gh' + const second = '/Users/tester/.volta/bin/gh' + const third = '/usr/local/bin/gh' + + const spawnedCommands = () => execFileAsyncMock.mock.calls.map(([command]) => command) + + let pathBefore = '' + + beforeEach(() => { + pathBefore = process.env.PATH ?? '' + execFileAsyncMock.mockReset() + runProcessMock.mockReset() + listLocalCommandPathsMock.mockReset() + listLocalCommandPathsMock.mockResolvedValue([shim, second, third]) + }) + + afterEach(() => { + vi.restoreAllMocks() + process.env.PATH = pathBefore + }) + + it('returns the first copy that runs and leaves the rest alone', async () => { + execFileAsyncMock.mockImplementation(async (command: string) => { + if (command === shim) { + throw Object.assign(new Error('cannot execute'), { code: 126 }) + } + return { stdout: 'gh version 2.98.0\n', stderr: '' } + }) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'available', + binary: second + }) + expect(spawnedCommands()).toEqual([shim, second]) + }) + + it('keeps looking when a copy exits non-zero', async () => { + execFileAsyncMock.mockImplementation(async (command: string) => { + if (command === third) { + return { stdout: 'gh version 2.98.0\n', stderr: '' } + } + throw Object.assign(new Error('Command failed'), { code: 1 }) + }) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'available', + binary: third + }) + }) + + it('stops at a timed-out copy instead of paying the timeout once per copy', async () => { + execFileAsyncMock.mockImplementation(async (command: string) => { + if (command === second) { + throw Object.assign(new Error('Timed out'), { killed: true, code: null }) + } + throw Object.assign(new Error('cannot execute'), { code: 126 }) + }) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'timeout', + binary: second + }) + expect(spawnedCommands()).toEqual([shim, second]) + }) + + it('stops on the ETIMEDOUT shape as well as the killed shape', async () => { + execFileAsyncMock.mockImplementation((command: string) => + command === second + ? Promise.reject(Object.assign(new Error('Timed out'), { code: 'ETIMEDOUT' })) + : Promise.reject(Object.assign(new Error('cannot execute'), { code: 126 })) + ) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'timeout', + binary: second + }) + expect(spawnedCommands()).toEqual([shim, second]) + }) + + it('reads a rejection that is not an object as an ordinary failure', async () => { + execFileAsyncMock.mockImplementation((command: string) => + command === third + ? Promise.resolve({ stdout: 'gh version 2.98.0\n', stderr: '' }) + : Promise.reject('not an error object') + ) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'available', + binary: third + }) + expect(spawnedCommands()).toEqual([shim, second, third]) + }) + + it('probes the bare command name when fs finds no candidate', async () => { + listLocalCommandPathsMock.mockResolvedValue([]) + execFileAsyncMock.mockResolvedValue({ stdout: 'gh version 2.98.0\n', stderr: '' }) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'available', + binary: 'gh' + }) + expect(spawnedCommands()).toEqual(['gh']) + }) + + it('probes a relative PATH entry as the absolute directory cwd gives it', async () => { + const relativeDir = path.join('.', 'tools') + process.env.PATH = [shim.replace('/gh', ''), relativeDir].join(path.delimiter) + const absoluteDir = path.resolve(relativeDir) + const hidden = `${absoluteDir}/gh` + const absolutePath = [shim.replace('/gh', ''), absoluteDir].join(path.delimiter) + listLocalCommandPathsMock.mockImplementation( + async (_command: string, options?: { env?: NodeJS.ProcessEnv }) => + options?.env?.PATH === absolutePath ? [shim, hidden] : [] + ) + execFileAsyncMock.mockImplementation(async (command: string) => { + if (command === hidden) { + return { stdout: 'gh version 2.98.0\n', stderr: '' } + } + throw Object.assign(new Error('cannot execute'), { code: 126 }) + }) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'available', + binary: hidden + }) + expect(spawnedCommands()).toEqual([shim, hidden]) + }) + + it('does not pay for the bare name when every PATH entry is absolute', async () => { + process.env.PATH = ['/Users/tester/.asdf/shims', '/usr/local/bin'].join(path.delimiter) + execFileAsyncMock.mockRejectedValue(Object.assign(new Error('cannot execute'), { code: 126 })) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'exec_failed', + binary: third + }) + expect(spawnedCommands()).not.toContain('gh') + }) + + it('runs an explicitly selected Windows cmd shim through the shared runner', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const command = path.win32.join('C:\\tools', 'gh.cmd') + runProcessMock.mockResolvedValue({ + code: 0, + stdout: 'gh version fixture', + stderr: '', + timedOut: false + }) + + await expect(findRunnableLocalCommand(command)).resolves.toEqual({ + status: 'available', + binary: command + }) + expect(runProcessMock).toHaveBeenCalledWith({ + program: command, + args: ['--version'], + env: undefined, + timeoutMs: expect.any(Number) + }) + expect(execFileAsyncMock).not.toHaveBeenCalled() + expect(listLocalCommandPathsMock).not.toHaveBeenCalled() + }) + + it('keeps searching Windows PATH after a broken cmd shim', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const command = path.win32.join('C:\\tools', 'gh.cmd') + const working = path.win32.join('C:\\working', 'gh.exe') + listLocalCommandPathsMock.mockResolvedValue([command, working]) + runProcessMock.mockResolvedValue({ + code: 126, + stdout: '', + stderr: 'broken shim', + timedOut: false + }) + execFileAsyncMock.mockResolvedValue({ stdout: 'gh version fixture', stderr: '' }) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'available', + binary: working + }) + expect(runProcessMock).toHaveBeenCalledOnce() + expect(spawnedCommands()).toEqual([working]) + }) + + it('stops on a shared runner timeout', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const command = path.win32.join('C:\\tools', 'gh.cmd') + listLocalCommandPathsMock.mockResolvedValue([command, third]) + runProcessMock.mockResolvedValue({ code: null, stdout: '', stderr: '', timedOut: true }) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'timeout', + binary: command + }) + expect(execFileAsyncMock).not.toHaveBeenCalled() + }) + + it('shares the five second timeout across failed candidates', async () => { + let now = 1000 + vi.spyOn(Date, 'now').mockImplementation(() => now) + execFileAsyncMock.mockImplementation(async (command: string) => { + if (command === shim) { + now += 4000 + throw Object.assign(new Error('cannot execute'), { code: 126 }) + } + throw Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }) + }) + + await expect(findRunnableLocalCommand('gh')).resolves.toEqual({ + status: 'timeout', + binary: second + }) + expect(execFileAsyncMock.mock.calls.map(([, , options]) => options.timeout)).toEqual([ + 5000, 1000 + ]) + }) +}) diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts index 9c67045d512..41bc378fdfe 100644 --- a/src/main/ipc/preflight-command-exec.ts +++ b/src/main/ipc/preflight-command-exec.ts @@ -1,8 +1,15 @@ import { execFile } from 'node:child_process' import path from 'node:path' +import { homedir } from 'node:os' import { promisify } from 'node:util' import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' -import { isCommandOnLocalPath } from './command-path-resolver' +import { getSystemCliInstallDirectories } from '../../shared/system-cli-install-dirs' +import { runProcess } from '../../shared/child-process/run-process' +import { + beginLocalCommandSelection, + isCommandOnLocalPath, + listLocalCommandPaths +} from './command-path-resolver' import { buildLocalPreflightEnv } from './preflight-local-env' import { runPreflightCommandInWsl } from './preflight-wsl-command' import type { WslPreflightTarget } from './preflight-wsl-agent-detection' @@ -17,7 +24,11 @@ export function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } -async function withPreflightTimeout(command: string, commandPromise: Promise): Promise { +async function withPreflightTimeout( + command: string, + commandPromise: Promise, + timeoutMs = PREFLIGHT_COMMAND_TIMEOUT_MS +): Promise { let timeout: ReturnType | null = null try { return await Promise.race([ @@ -28,7 +39,7 @@ async function withPreflightTimeout(command: string, commandPromise: Promise< code: 'ETIMEDOUT' }) reject(error) - }, PREFLIGHT_COMMAND_TIMEOUT_MS) + }, timeoutMs) if (typeof timeout.unref === 'function') { timeout.unref() } @@ -47,19 +58,36 @@ async function withPreflightTimeout(command: string, commandPromise: Promise< * docs/reference/wsl-probe-failure-semantics.md before doing so. */ export async function execLocalPreflightCommandOrThrow( command: string, - args: string[] + args: string[], + options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {} ): Promise { - const env = buildLocalPreflightEnv() + const env = options.env ?? buildLocalPreflightEnv() + const timeoutMs = options.timeoutMs ?? PREFLIGHT_COMMAND_TIMEOUT_MS + // Node cannot execFile a batch shim; the shared runner handles its argv safely. + if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command)) { + const result = await withPreflightTimeout( + command, + runProcess({ program: command, args, env, timeoutMs }), + timeoutMs + ) + if (result.timedOut || result.code !== 0) { + throw Object.assign(new Error(`Failed running ${command}`), { + ...result, + code: result.timedOut ? 'ETIMEDOUT' : result.code + }) + } + return { stdout: result.stdout, stderr: result.stderr } + } const commandPromise = execFileAsync(command, args, { encoding: 'utf-8', - timeout: PREFLIGHT_COMMAND_TIMEOUT_MS, + timeout: timeoutMs, // Preflight probes console-subsystem binaries (git, gh, node); without this // each one flashes a console and steals foreground on Windows (#10488). windowsHide: true, ...(env ? { env } : {}) - }) as Promise + }) - return withPreflightTimeout(command, commandPromise) + return withPreflightTimeout(command, commandPromise, timeoutMs) } // Throws on any failure — a distro that is booting/unreachable throws the @@ -76,14 +104,106 @@ export async function execCommandInWslOrThrow( return withPreflightTimeout('wsl command', commandPromise) } +const PREFLIGHT_LOCAL_PROBE_LIMIT = 4 + +export type LocalCommandProbe = + | { status: 'available'; binary: string } + | { status: 'absent' } + | { status: 'exec_failed' | 'timeout' | 'limit_reached'; binary: string } + +function probeTimedOut(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false + } + return ( + ('killed' in error && error.killed === true) || ('code' in error && error.code === 'ETIMEDOUT') + ) +} + +async function localProbeCandidates( + command: string, + env: NodeJS.ProcessEnv | undefined +): Promise { + const isWin = process.platform === 'win32' + const probeEnv = env ?? process.env + // Keep relative PATH entries in their original position, as execFile does. + const absoluteEnv = isWin + ? probeEnv + : { + ...probeEnv, + PATH: (probeEnv.PATH ?? '') + .split(path.delimiter) + .map((dir) => path.resolve(dir)) + .join(path.delimiter) + } + const maxResults = PREFLIGHT_LOCAL_PROBE_LIMIT + 1 + const paths = await listLocalCommandPaths(command, { env: absoluteEnv, maxResults }) + const installPaths = + isWin || paths.length >= maxResults + ? [] + : await listLocalCommandPaths(command, { + env: { + PATH: getSystemCliInstallDirectories(process.platform, homedir()).join(path.delimiter) + }, + maxResults + }) + return [...new Set([...paths, ...installPaths])] +} + +/** Try only version probes; authentication must stay on the selected binary. */ +export async function findRunnableLocalCommand(command: string): Promise { + const publishSelection = beginLocalCommandSelection(command) + const result = await probeRunnableLocalCommand(command) + await publishSelection(result.status === 'available' ? result.binary : null) + return result +} + +async function probeRunnableLocalCommand(command: string): Promise { + const env = buildLocalPreflightEnv() + const explicit = command.includes('/') || (process.platform === 'win32' && command.includes('\\')) + // An explicit path is the user's selection, even when it cannot run. + const candidates = explicit ? [] : await localProbeCandidates(command, env) + const probes = candidates.length ? candidates.slice(0, PREFLIGHT_LOCAL_PROBE_LIMIT) : [command] + const deadline = Date.now() + PREFLIGHT_COMMAND_TIMEOUT_MS + for (const binary of probes) { + const timeoutMs = deadline - Date.now() + if (timeoutMs <= 0) { + return { status: 'timeout', binary } + } + try { + await execLocalPreflightCommandOrThrow(binary, ['--version'], { env, timeoutMs }) + return { status: 'available', binary } + } catch (error) { + if (probeTimedOut(error)) { + return { status: 'timeout', binary } + } + if ( + candidates.length === 0 && + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' && + !(await isCommandOnLocalPath(explicit ? path.resolve(command) : command, { env })) + ) { + return { status: 'absent' } + } + } + } + return { + status: candidates.length > PREFLIGHT_LOCAL_PROBE_LIMIT ? 'limit_reached' : 'exec_failed', + binary: probes.at(-1) ?? command + } +} + export async function isCommandAvailable( command: string, wslTarget?: WslPreflightTarget ): Promise { + if (!wslTarget) { + return (await findRunnableLocalCommand(command)).status === 'available' + } try { - await (wslTarget - ? execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`) - : execLocalPreflightCommandOrThrow(command, ['--version'])) + await execCommandInWslOrThrow(wslTarget, `${shellQuote(command)} --version`) return true } catch { return false diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts index cc005da88e6..b68c3e8afb5 100644 --- a/src/main/ipc/preflight-host-cli-status.test.ts +++ b/src/main/ipc/preflight-host-cli-status.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as LocalCommandResolver from './command-path-resolver' const { handleMock, @@ -13,6 +14,7 @@ const { getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + listLocalCommandPathsMock, mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock } = vi.hoisted(() => ({ @@ -28,6 +30,7 @@ const { getGiteaAuthStatusMock: vi.fn(), resolveCliCommandsMock: vi.fn(), isCommandOnLocalPathMock: vi.fn(), + listLocalCommandPathsMock: vi.fn(), mergePersistedWindowsPathAsyncMock: vi.fn(), mergePersistedWindowsPathMock: vi.fn() })) @@ -63,8 +66,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({ // Why (#9297): local PATH resolution is now fs-based (no where/which spawn). // These tests express "which commands are on PATH" via the where/which mock, // so route the resolver through that same mock to preserve their intent. -vi.mock('./command-path-resolver', () => ({ - isCommandOnLocalPath: isCommandOnLocalPathMock +vi.mock('./command-path-resolver', async (importOriginal) => ({ + ...(await importOriginal()), + isCommandOnLocalPath: isCommandOnLocalPathMock, + listLocalCommandPaths: listLocalCommandPathsMock })) vi.mock('../pty/windows-environment-path', () => ({ @@ -115,6 +120,7 @@ describe('preflight', () => { getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + listLocalCommandPathsMock, mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock }, @@ -162,17 +168,29 @@ describe('preflight', () => { }) }) - it('treats gh as unauthenticated when gh auth status fails without auth markers', async () => { - execFileAsyncMock - .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) - .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) - .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) - .mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' }) - .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) + it.each(['gh', 'glab'])('does not switch %s copies after authentication fails', async (cli) => { + const first = `/test/first/${cli}` + const second = `/test/second/${cli}` + listLocalCommandPathsMock.mockImplementation(async (command: string) => + command === cli ? [first, second] : [] + ) + execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => { + if (command === first && args[0] === 'auth') { + throw Object.assign(new Error('not authenticated'), { code: 1, stderr: 'not logged in' }) + } + return { stdout: 'fixture success', stderr: '' } + }) const status = await runPreflightCheck() - expect(status.gh).toEqual({ installed: true, authenticated: false }) + expect(cli === 'gh' ? status.gh : status.glab).toEqual({ + installed: true, + authenticated: false + }) + expect( + execFileAsyncMock.mock.calls.filter(([command]) => command === first).map(([, args]) => args) + ).toEqual([['--version'], ['auth', 'status']]) + expect(execFileAsyncMock.mock.calls.some(([command]) => command === second)).toBe(false) }) it('keeps older gh stderr success output from showing a false auth warning', async () => { @@ -188,6 +206,88 @@ describe('preflight', () => { expect(status.gh).toEqual({ installed: true, authenticated: true }) }) + // Why (#22975): these two cases key the spawn mock on (command, args) instead + // of call order, because the fallback probes exactly which copy runs is the + // behaviour under test — and `gh auth status` runs in parallel with + // `glab auth status`, so a fixed sequence would not be the real one. + it('authenticates the gh copy that actually ran, not the shim PATH chose', async () => { + const shim = '/Users/octocat/.asdf/shims/gh' + const working = '/opt/homebrew/bin/gh' + const glabShim = '/Users/octocat/.asdf/shims/glab' + const glabWorking = '/usr/local/bin/glab' + const shims = new Set([shim, glabShim]) + listLocalCommandPathsMock.mockImplementation(async (command: string) => { + if (command === 'gh') { + return [shim, working] + } + return command === 'glab' ? [glabShim, glabWorking] : [] + }) + execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => { + if (shims.has(command)) { + throw Object.assign(new Error('spawn failed'), { code: 126 }) + } + const auth = args[0] === 'auth' + if (command === working) { + return { stdout: auth ? 'github.com\n - Active account: true\n' : 'gh version 2.6.0\n' } + } + if (command === glabWorking) { + return { stdout: auth ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n' } + } + if (command === 'git') { + return { stdout: 'git version 2.0.0\n' } + } + throw new Error(`unexpected command ${command}`) + }) + + const status = await runPreflightCheck() + + expect(status).toMatchObject({ + gh: { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true } + }) + for (const [cli, copy] of [ + ['gh', working], + ['glab', glabWorking] + ]) { + expect(execFileAsyncMock).toHaveBeenCalledWith(copy, ['auth', 'status'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true + }) + expect(execFileAsyncMock).not.toHaveBeenCalledWith(cli, ['auth', 'status'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true + }) + } + }) + + it('marks gh not installed when no copy PATH offers can run', async () => { + const doomed = ['/Users/octocat/.asdf/shims/gh', '/Users/octocat/.local/bin/gh'] + listLocalCommandPathsMock.mockImplementation(async (command: string) => + command === 'gh' ? doomed : [] + ) + execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => { + if (doomed.includes(command)) { + throw Object.assign(new Error('spawn failed'), { code: 126 }) + } + if (command === 'git') { + return { stdout: 'git version 2.0.0\n' } + } + if (command === 'glab') { + return { + stdout: args[0] === 'auth' ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n' + } + } + throw new Error(`unexpected command ${command}`) + }) + + const status = await runPreflightCheck() + + expect(status.gh).toEqual({ installed: false, authenticated: false }) + expect(execFileAsyncMock).toHaveBeenCalledTimes(5) + }) + it('marks glab as not installed when `glab --version` fails', async () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) diff --git a/src/main/ipc/preflight-provider-command-selection.test.ts b/src/main/ipc/preflight-provider-command-selection.test.ts new file mode 100644 index 00000000000..a51ac4a886c --- /dev/null +++ b/src/main/ipc/preflight-provider-command-selection.test.ts @@ -0,0 +1,321 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import { ghExecFileAsync } from '../git/command-runner/gh-exec-file' +import { glabExecFileAsync } from '../git/command-runner/glab-exec-file' +import { execFileCaptureToTermination } from '../git/command-runner/exec-file-capture' +import { beginLocalCommandSelection, resolveSelectedLocalCommand } from './command-path-resolver' +import { + execLocalPreflightCommandOrThrow, + findRunnableLocalCommand, + shellQuote +} from './preflight-command-exec' + +vi.mock('../../shared/system-cli-install-dirs', () => ({ + getSystemCliInstallDirectories: (_platform: NodeJS.Platform, home: string) => [ + path.join(home, '.nix-profile', 'bin') + ] +})) + +let root = '' +const CLIS = ['gh', 'glab'] as const + +async function fixtureCli( + cli: string, + label: string, + body = `case "$1" in\n--version) echo fixture-version;;\nauth) echo 'Logged in fixture';;\napi) echo '${label}-api';;\nesac\n` +): Promise { + const dir = path.join(root, label) + await mkdir(dir, { recursive: true }) + const binary = path.join(dir, cli) + await writeFile( + binary, + `#!/bin/sh\nprintf '%s\\n' "$*" >> ${shellQuote(`${binary}-calls`)}\n${body}`, + { mode: 0o755 } + ) + return binary +} + +async function calls(binary: string): Promise { + try { + return (await readFile(`${binary}-calls`, 'utf8')).trim().split('\n') + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { + return [] + } + throw error + } +} + +function providerCommand(cli: 'gh' | 'glab', env?: NodeJS.ProcessEnv, cwd?: string) { + const execute = cli === 'gh' ? ghExecFileAsync : glabExecFileAsync + return execute(['api', 'user'], { timeout: 1000, idempotent: false, env, cwd }) +} + +describe.skipIf(process.platform === 'win32')( + 'preflight selection in native provider commands', + () => { + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'orca-22975-provider-')) + vi.stubEnv('HOME', root) + }) + + afterEach(async () => { + vi.unstubAllEnvs() + await removeTree(root) + }) + + it.each(CLIS)( + 'uses the same runnable %s for version, auth and provider operations', + async (cli) => { + const shim = await fixtureCli(cli, 'shim', 'echo broken-shim >&2\nexit 126\n') + const good = await fixtureCli(cli, 'good') + vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter)) + + const selected = await findRunnableLocalCommand(cli) + expect(selected).toEqual({ status: 'available', binary: good }) + if (selected.status !== 'available') { + throw new Error('Expected the working fixture to be selected') + } + await expect( + execLocalPreflightCommandOrThrow(selected.binary, ['auth', 'status']) + ).resolves.toMatchObject({ stdout: 'Logged in fixture\n' }) + await expect(providerCommand(cli, undefined, root)).resolves.toMatchObject({ + stdout: 'good-api\n' + }) + expect(await calls(shim)).toEqual(['--version']) + expect(await calls(good)).toEqual(['--version', 'auth status', 'api user']) + } + ) + + it.each(CLIS)( + 'keeps the proven %s usable while its version selection refreshes', + async (cli) => { + const shim = await fixtureCli(cli, 'shim', 'echo broken-shim >&2\nexit 126\n') + const good = await fixtureCli(cli, 'good') + vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter)) + await findRunnableLocalCommand(cli) + + const refreshing = findRunnableLocalCommand(cli) + await expect(providerCommand(cli)).resolves.toMatchObject({ stdout: 'good-api\n' }) + await expect(refreshing).resolves.toEqual({ status: 'available', binary: good }) + expect(await calls(shim)).toEqual(['--version', '--version']) + } + ) + + it('clears the previous selection only after the newest version probe fails', async () => { + const shim = await fixtureCli('gh', 'shim', 'exit 126\n') + const good = await fixtureCli( + 'gh', + 'good', + 'if [ "$1" = --version ] && [ "$ORCA_22975_VERSION_DISABLED" = 1 ]; then exit 126; fi\necho good-api\n' + ) + vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter)) + await findRunnableLocalCommand('gh') + vi.stubEnv('ORCA_22975_VERSION_DISABLED', '1') + + const refreshing = findRunnableLocalCommand('gh') + await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'good-api\n' }) + await expect(refreshing).resolves.toMatchObject({ status: 'exec_failed' }) + expect(resolveSelectedLocalCommand('gh')).toBe('gh') + }) + + it.each(CLIS)('also uses the recovered %s from a known install directory', async (cli) => { + const shim = await fixtureCli(cli, 'shim', 'exit 126\n') + const good = await fixtureCli(cli, path.join('.nix-profile', 'bin')) + vi.stubEnv('PATH', path.dirname(shim)) + + await expect(findRunnableLocalCommand(cli)).resolves.toEqual({ + status: 'available', + binary: good + }) + await expect(providerCommand(cli)).resolves.toMatchObject({ + stdout: `${path.join('.nix-profile', 'bin')}-api\n` + }) + expect(await calls(shim)).toEqual(['--version']) + }) + + it.each(CLIS)('keeps %s selected when authentication fails', async (cli) => { + const first = await fixtureCli( + cli, + 'first', + 'if [ "$1" = auth ]; then echo unauthenticated >&2; exit 1; fi\necho first-result\n' + ) + const other = await fixtureCli(cli, 'other') + vi.stubEnv('PATH', [path.dirname(first), path.dirname(other)].join(path.delimiter)) + + await findRunnableLocalCommand(cli) + await expect( + execLocalPreflightCommandOrThrow(first, ['auth', 'status']) + ).rejects.toMatchObject({ + code: 1 + }) + await expect(providerCommand(cli)).resolves.toMatchObject({ stdout: 'first-result\n' }) + expect(await calls(other)).toEqual([]) + }) + + it.each(CLIS)('never retries a failed %s operation on another binary', async (cli) => { + const first = await fixtureCli( + cli, + 'first', + 'if [ "$1" = api ]; then echo operation-failed >&2; exit 126; fi\necho fixture-version\n' + ) + const other = await fixtureCli(cli, 'other') + vi.stubEnv('PATH', [path.dirname(first), path.dirname(other)].join(path.delimiter)) + + await findRunnableLocalCommand(cli) + await expect(providerCommand(cli)).rejects.toMatchObject({ + code: 126, + stderr: 'operation-failed\n' + }) + expect(await calls(first)).toEqual(['--version', 'api user']) + expect(await calls(other)).toEqual([]) + }) + + it('respects a caller PATH and explicit binary instead of the global selection', async () => { + const shim = await fixtureCli('gh', 'shim', 'exit 126\n') + const good = await fixtureCli('gh', 'good') + const custom = await fixtureCli('gh', 'custom') + vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter)) + await findRunnableLocalCommand('gh') + const refreshing = findRunnableLocalCommand('gh') + + await expect( + providerCommand('gh', { ...process.env, PATH: path.dirname(custom) }) + ).resolves.toMatchObject({ stdout: 'custom-api\n' }) + await expect( + execFileCaptureToTermination(custom, ['api', 'user'], { encoding: 'utf8', timeout: 1000 }) + ).resolves.toMatchObject({ stdout: 'custom-api\n' }) + await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'good-api\n' }) + await expect(refreshing).resolves.toEqual({ status: 'available', binary: good }) + }) + + it('bypasses a selection after PATH or relative-PATH cwd changes', async () => { + const good = await fixtureCli('gh', 'good') + const custom = await fixtureCli('gh', 'custom') + vi.stubEnv('PATH', path.dirname(good)) + await findRunnableLocalCommand('gh') + const refreshing = findRunnableLocalCommand('gh') + vi.stubEnv('PATH', path.dirname(custom)) + await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'custom-api\n' }) + await refreshing + await expect(providerCommand('gh')).resolves.toMatchObject({ stdout: 'custom-api\n' }) + + vi.stubEnv('PATH', path.relative(process.cwd(), path.dirname(good))) + await findRunnableLocalCommand('gh') + expect(resolveSelectedLocalCommand('gh', { cwd: root })).toBe('gh') + }) + + it('discards a replaced binary and clears a selection when the next probe fails', async () => { + const shim = await fixtureCli('gh', 'shim', 'exit 126\n') + const good = await fixtureCli('gh', 'good') + vi.stubEnv('PATH', [path.dirname(shim), path.dirname(good)].join(path.delimiter)) + await findRunnableLocalCommand('gh') + await writeFile(good, '#!/bin/sh\necho changed >&2\nexit 126\n') + + expect(resolveSelectedLocalCommand('gh')).toBe('gh') + await expect(findRunnableLocalCommand('gh')).resolves.toMatchObject({ status: 'exec_failed' }) + expect(resolveSelectedLocalCommand('gh')).toBe('gh') + await fixtureCli('gh', 'good') + await findRunnableLocalCommand('gh') + expect(resolveSelectedLocalCommand('gh')).toBe(good) + await rm(good) + expect(resolveSelectedLocalCommand('gh')).toBe('gh') + }) + + it('keeps an older concurrent probe from replacing the newer selection', async () => { + const first = await fixtureCli('gh', 'first') + const second = await fixtureCli('gh', 'second') + const older = beginLocalCommandSelection('gh') + const newer = beginLocalCommandSelection('gh') + await newer(second) + await older(first) + await older(null) + + expect(resolveSelectedLocalCommand('gh')).toBe(second) + }) + } +) + +describe('Windows selection across working folders', () => { + it('keeps an absolute PATH selection in a different provider cwd', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-22975-cwd-')) + const binary = path.join(directory, 'gh.CMD') + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform') + await writeFile(binary, '@echo off\r\nexit /b 0\r\n') + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.stubEnv('PATH', directory) + const publish = beginLocalCommandSelection('gh') + await publish(binary) + expect(resolveSelectedLocalCommand('gh', { cwd: path.join(directory, 'project') })).toBe( + binary + ) + } finally { + vi.unstubAllEnvs() + if (descriptor) { + Object.defineProperty(process, 'platform', descriptor) + } + await removeTree(directory) + } + }) + + it('does not carry a current-directory CLI into another folder', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-22975-local-cwd-')) + const binary = path.join(directory, 'gh.CMD') + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform') + await writeFile(binary, '@echo off\r\nexit /b 0\r\n') + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(directory) + try { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.stubEnv('PATH', path.join(directory, 'other')) + const publish = beginLocalCommandSelection('gh') + await publish(binary) + expect(resolveSelectedLocalCommand('gh')).toBe(binary) + expect(resolveSelectedLocalCommand('gh', { cwd: path.join(directory, 'project') })).toBe('gh') + } finally { + cwd.mockRestore() + vi.unstubAllEnvs() + if (descriptor) { + Object.defineProperty(process, 'platform', descriptor) + } + await removeTree(directory) + } + }) +}) + +describe.runIf(process.platform === 'win32')('native provider batch selection', () => { + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'orca-22975-provider-cmd-')) + }) + + afterEach(async () => { + vi.unstubAllEnvs() + await removeTree(root) + }) + + it.each(CLIS)('passes the selected %s.cmd through the native runner', async (cli) => { + const shim = path.join(root, 'shim') + const good = path.join(root, 'good') + await mkdir(shim) + await mkdir(good) + await writeFile(path.join(shim, `${cli}.CMD`), '@echo off\r\nexit /b 126\r\n') + await writeFile( + path.join(good, `${cli}.CMD`), + '@echo off\r\nif "%~1"=="api" (echo good-api) else (echo fixture-version)\r\nexit /b 0\r\n' + ) + vi.stubEnv('PATH', [shim, good].join(path.delimiter)) + vi.stubEnv('Path', [shim, good].join(path.delimiter)) + vi.stubEnv('PATHEXT', '.CMD') + + await expect(findRunnableLocalCommand(cli)).resolves.toEqual({ + status: 'available', + binary: path.posix.join(good, `${cli}.CMD`) + }) + await expect(providerCommand(cli, undefined, root)).resolves.toMatchObject({ + stdout: expect.stringContaining('good-api') + }) + }) +}) diff --git a/src/main/ipc/preflight-remote-ssh.test.ts b/src/main/ipc/preflight-remote-ssh.test.ts index 14d79e953e8..4ad02ec5d04 100644 --- a/src/main/ipc/preflight-remote-ssh.test.ts +++ b/src/main/ipc/preflight-remote-ssh.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as LocalCommandResolver from './command-path-resolver' const { handleMock, @@ -12,6 +13,7 @@ const { getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + listLocalCommandPathsMock, mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock } = vi.hoisted(() => ({ @@ -26,6 +28,7 @@ const { getGiteaAuthStatusMock: vi.fn(), resolveCliCommandsMock: vi.fn(), isCommandOnLocalPathMock: vi.fn(), + listLocalCommandPathsMock: vi.fn(), mergePersistedWindowsPathAsyncMock: vi.fn(), mergePersistedWindowsPathMock: vi.fn() })) @@ -58,8 +61,10 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({ // Why (#9297): local PATH resolution is now fs-based (no where/which spawn). // These tests express "which commands are on PATH" via the where/which mock, // so route the resolver through that same mock to preserve their intent. -vi.mock('./command-path-resolver', () => ({ - isCommandOnLocalPath: isCommandOnLocalPathMock +vi.mock('./command-path-resolver', async (importOriginal) => ({ + ...(await importOriginal()), + isCommandOnLocalPath: isCommandOnLocalPathMock, + listLocalCommandPaths: listLocalCommandPathsMock })) vi.mock('../pty/windows-environment-path', () => ({ @@ -106,6 +111,7 @@ describe('preflight', () => { getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + listLocalCommandPathsMock, mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock }, diff --git a/src/main/ipc/preflight-runnable-local-cli.test.ts b/src/main/ipc/preflight-runnable-local-cli.test.ts new file mode 100644 index 00000000000..2f41efd7b78 --- /dev/null +++ b/src/main/ipc/preflight-runnable-local-cli.test.ts @@ -0,0 +1,249 @@ +import { chmod, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { removeTree } from '../../shared/windows-transient-lock-removal' +import { + execLocalPreflightCommandOrThrow, + findRunnableLocalCommand, + isCommandAvailable, + isCommandOnPath, + shellQuote +} from './preflight-command-exec' + +const COMMAND = 'orca-22975-gh' +const BROKEN_SHIM = 'echo broken-shim >&2\nexit 126\n' +let root = '' + +async function cliInDirectory(label: string, body = 'echo gh-version-fixture\n'): Promise { + const dir = path.join(root, label) + await mkdir(dir, { recursive: true }) + await writeFile( + path.join(dir, COMMAND), + `#!/bin/sh\nprintf '%s\\n' "$@" >> ${shellQuote(path.join(dir, 'argv.txt'))}\n${body}`, + { mode: 0o755 } + ) + return dir +} + +async function probedArgs(dir: string): Promise { + try { + return (await readFile(path.join(dir, 'argv.txt'), 'utf8')).split('\n').filter(Boolean) + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { + return [] + } + throw error + } +} + +describe.skipIf(process.platform === 'win32')( + 'local CLI version probes with real processes', + () => { + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'orca-22975-case-')) + vi.stubEnv('HOME', root) + }) + + afterEach(async () => { + vi.unstubAllEnvs() + await removeTree(root) + }) + + it('detects a runnable copy behind an executable shim that exits 126', async () => { + const shim = await cliInDirectory('shim', BROKEN_SHIM) + const good = await cliInDirectory('good') + vi.stubEnv('PATH', [shim, good].join(path.delimiter)) + + await expect(isCommandOnPath(COMMAND)).resolves.toBe(true) + await expect( + execLocalPreflightCommandOrThrow(path.join(shim, COMMAND), ['--version']) + ).rejects.toMatchObject({ code: 126, stderr: 'broken-shim\n' }) + await expect( + execLocalPreflightCommandOrThrow(path.join(good, COMMAND), ['--version']) + ).resolves.toMatchObject({ stdout: 'gh-version-fixture\n' }) + await expect(isCommandAvailable(COMMAND)).resolves.toBe(true) + await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({ + status: 'available', + binary: path.join(good, COMMAND) + }) + }) + + it('keeps PATH order and leaves later copies unprobed when the first works', async () => { + const first = await cliInDirectory('first') + const second = await cliInDirectory('second') + vi.stubEnv('PATH', [first, second].join(path.delimiter)) + + await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({ + status: 'available', + binary: path.join(first, COMMAND) + }) + expect(await probedArgs(first)).toEqual(['--version']) + expect(await probedArgs(second)).toEqual([]) + }) + + it('distinguishes exhausted failing copies from an absent command', async () => { + const first = await cliInDirectory('first', BROKEN_SHIM) + const second = await cliInDirectory('second', BROKEN_SHIM) + vi.stubEnv('PATH', [first, second].join(path.delimiter)) + + await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({ + status: 'exec_failed', + binary: path.join(second, COMMAND) + }) + await expect(findRunnableLocalCommand('orca-22975-absent-cli')).resolves.toEqual({ + status: 'absent' + }) + expect(await probedArgs(first)).toEqual(['--version']) + expect(await probedArgs(second)).toEqual(['--version']) + }) + + it('skips directories and non-executable files before a working copy', async () => { + const directory = path.join(root, 'directory') + await mkdir(path.join(directory, COMMAND), { recursive: true }) + const plain = await cliInDirectory('plain') + await chmod(path.join(plain, COMMAND), 0o644) + const good = await cliInDirectory('good') + vi.stubEnv('PATH', [directory, plain, good].join(path.delimiter)) + + await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({ + status: 'available', + binary: path.join(good, COMMAND) + }) + expect(await probedArgs(plain)).toEqual([]) + }) + + it('never substitutes another binary for an explicit failing path', async () => { + const shim = await cliInDirectory('shim', BROKEN_SHIM) + const good = await cliInDirectory('good') + vi.stubEnv('PATH', good) + const selected = path.join(shim, COMMAND) + + await expect(findRunnableLocalCommand(selected)).resolves.toEqual({ + status: 'exec_failed', + binary: selected + }) + expect(await probedArgs(good)).toEqual([]) + }) + + it('executes relative explicit paths and reports an explicit missing path', async () => { + const good = await cliInDirectory('good') + const selected = path.relative(process.cwd(), path.join(good, COMMAND)) + vi.stubEnv('PATH', '') + + await expect(findRunnableLocalCommand(selected)).resolves.toEqual({ + status: 'available', + binary: selected + }) + await expect(findRunnableLocalCommand(path.join(root, 'missing', COMMAND))).resolves.toEqual({ + status: 'absent' + }) + }) + + it.each([true, false])( + 'preserves relative PATH order with shim first: %s', + async (shimFirst) => { + const good = await cliInDirectory('good') + const shim = await cliInDirectory('shim', BROKEN_SHIM) + const relative = path.relative(process.cwd(), good) + vi.stubEnv('PATH', (shimFirst ? [shim, relative] : [relative, shim]).join(path.delimiter)) + + await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({ + status: 'available', + binary: path.join(good, COMMAND) + }) + expect(await probedArgs(good)).toEqual(['--version']) + expect(await probedArgs(shim)).toEqual(shimFirst ? ['--version'] : []) + } + ) + + it('deduplicates PATH entries before spending the probe limit', async () => { + const shim = await cliInDirectory('shim', BROKEN_SHIM) + const good = await cliInDirectory('good') + vi.stubEnv('PATH', [shim, shim, shim, shim, good].join(path.delimiter)) + + await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({ + status: 'available', + binary: path.join(good, COMMAND) + }) + expect(await probedArgs(shim)).toEqual(['--version']) + }) + + it('caps execution at four distinct candidates and reports the limit', async () => { + const doomed: string[] = [] + for (const index of [0, 1, 2, 3]) { + doomed.push(await cliInDirectory(`doomed-${index}`, BROKEN_SHIM)) + } + const good = await cliInDirectory('past-cap') + vi.stubEnv('PATH', [...doomed, good].join(path.delimiter)) + + await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({ + status: 'limit_reached', + binary: path.join(doomed[3], COMMAND) + }) + for (const dir of doomed) { + expect(await probedArgs(dir)).toEqual(['--version']) + } + expect(await probedArgs(good)).toEqual([]) + }) + + it('stops at a real timeout and leaves later copies unprobed', async () => { + const hung = await cliInDirectory('hung', 'exec /bin/sleep 30\n') + const good = await cliInDirectory('good') + vi.stubEnv('PATH', [hung, good].join(path.delimiter)) + + await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({ + status: 'timeout', + binary: path.join(hung, COMMAND) + }) + expect(await probedArgs(good)).toEqual([]) + }, 10_000) + + it('uses the known Nix install directory after PATH copies fail', async () => { + const shim = await cliInDirectory('shim', BROKEN_SHIM) + const profile = await cliInDirectory(path.join('.nix-profile', 'bin')) + vi.stubEnv('PATH', shim) + + await expect(findRunnableLocalCommand(COMMAND)).resolves.toEqual({ + status: 'available', + binary: path.join(profile, COMMAND) + }) + }) + } +) + +describe.runIf(process.platform === 'win32')('Windows preflight batch shims', () => { + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'orca-22975-cmd-')) + }) + + afterEach(async () => { + vi.unstubAllEnvs() + await removeTree(root) + }) + + it('runs the next cmd shim after an earlier copy exits 126', async () => { + const first = path.join(root, 'first') + const second = path.join(root, 'second') + for (const dir of [first, second]) { + await mkdir(dir) + } + const name = `${COMMAND}.CMD` + await writeFile(path.join(first, name), '@echo off\r\nexit /b 126\r\n') + await writeFile( + path.join(second, name), + '@echo off\r\necho gh-version-fixture\r\nexit /b 0\r\n' + ) + vi.stubEnv('PATH', [first, second].join(path.delimiter)) + vi.stubEnv('Path', [first, second].join(path.delimiter)) + + const result = await findRunnableLocalCommand(COMMAND) + + expect(result).toEqual({ status: 'available', binary: path.posix.join(second, name) }) + if (result.status === 'available') { + await expect( + execLocalPreflightCommandOrThrow(result.binary, ['auth', 'status']) + ).resolves.toMatchObject({ stdout: expect.stringContaining('gh-version-fixture') }) + } + }) +}) diff --git a/src/main/ipc/preflight-test-harness.ts b/src/main/ipc/preflight-test-harness.ts index abdee69985c..e38848ad5eb 100644 --- a/src/main/ipc/preflight-test-harness.ts +++ b/src/main/ipc/preflight-test-harness.ts @@ -16,6 +16,7 @@ export type PreflightMocks = { getGiteaAuthStatusMock: Mock resolveCliCommandsMock: Mock isCommandOnLocalPathMock: Mock + listLocalCommandPathsMock: Mock mergePersistedWindowsPathAsyncMock: Mock mergePersistedWindowsPathMock: Mock } @@ -52,6 +53,7 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap) getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + listLocalCommandPathsMock, mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock } = mocks @@ -63,6 +65,11 @@ export function resetPreflightMocks(mocks: PreflightMocks, handlers: HandlerMap) hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' }) mergePathSegmentsMock.mockReset() getActiveMultiplexerMock.mockReset() + // Why empty by default: with no fs candidates the local probe keeps its + // historical bare-name spawn, so cases that stub only where/which and execFile + // still assert on the command names they were written against. + listLocalCommandPathsMock.mockReset() + listLocalCommandPathsMock.mockResolvedValue([]) getBitbucketAuthStatusMock.mockReset() getAzureDevOpsAuthStatusMock.mockReset() getGiteaAuthStatusMock.mockReset() diff --git a/src/main/ipc/pty-controller-process-inventory.test.ts b/src/main/ipc/pty-controller-process-inventory.test.ts index 004735c61cb..50b67963ab9 100644 --- a/src/main/ipc/pty-controller-process-inventory.test.ts +++ b/src/main/ipc/pty-controller-process-inventory.test.ts @@ -1,3 +1,4 @@ +import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry' import { afterEach, describe, expect, it, vi } from 'vitest' const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ @@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({ }) })) -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - }, - openCode2HookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - } -})) +vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock()) vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index f49978843d5..4eba8d0b164 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record) => ({ }) export const openCodeHookServiceModuleMock = () => ({ + OpenCodeHookService: class { + buildPtyEnv = vi.fn(() => ({})) + }, openCodeHookService: { buildPtyEnv: openCodeBuildPtyEnvMock, refreshLegacySharedPlugin: vi.fn<() => void>(), diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 224238bff9a..d530a9cd194 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -11,6 +11,7 @@ import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir' import { makePaneKey } from '../../shared/stable-pane-id' +import { selectShellStartupFeatures } from '../shell-startup-features' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -60,6 +61,41 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite() describe('spawn environment', () => { + it.each(['/bin/bash', '/bin/zsh'])( + 'does not wrap a bare %s pane merely to expose this app CLI', + (shellPath) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + try { + const env = buildPtyHostEnv( + 'bare-cli-pane', + {}, + { + isPackaged: false, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: false + } + ) + expect(env.ORCA_CLI_BIN_DIR).toBe('/tmp/orca-user-data/cli/bin') + expect( + selectShellStartupFeatures({ + shellPath, + env, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + ).toEqual([]) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + } + ) + it('does not install managed Pi extensions when Pi is disabled', () => { piBuildPtyEnvMock.mockClear() diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 1cae20bfcbd..8b921b4ebde 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -1,15 +1,10 @@ import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' -import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' -import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' -import { - OPENCODE_CONFIG_DIR_ENV_KEYS, - isOpenCodeLegacySharedConfigDir -} from '../../../opencode/legacy-shared-config-dir' +import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' @@ -28,13 +23,13 @@ import { exposePiManagedExtensionEnv, isMimoLaunchCommand, resolveMimocodeSourceHome, - resolveOpenCodeSourceConfigDir, resolvePiAgentSourceDir, resolveScopedPiAgentSourceDir, restoreOrStripOverlayEnv } from './pi-agent' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment' +import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config' /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. @@ -50,32 +45,9 @@ export function buildPtyHostEnv( mergePersistedWindowsPath(baseEnv) Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - // Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config. - const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean => - isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath) - const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {} - for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { - if (isLegacyOpenCodeHooksDir(baseEnv[key])) { - delete baseEnv[key] - } - if (!isLegacyOpenCodeHooksDir(process.env[key])) { - inheritedOpenCodeEnv[key] = process.env[key] - } - } - // A daemon or sibling shell can retain a retired path that main no longer sees. - openCodeHookService.refreshLegacySharedPlugin() - openCode2HookService.refreshLegacySharedPlugin() - const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv) - const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir) - ? undefined - : resolvedOpenCodeConfigDir + const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint }) - const openCodeAgent = selectOpenCodeHookAgent( - opts.launchAgent, - launchCommandHint, - (agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents) - ) const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) ? opts.launchAgent : opts.launchAgent === undefined @@ -110,37 +82,13 @@ export function buildPtyHostEnv( ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - restoreOrStripOverlayEnv( + const openCodeAgent = applyOpenCodeStatusPluginEnv( + id, baseEnv, - { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', - preserveExplicitPrimary: true - }, - inheritedOpenCodeEnv + openCodeConfig, + opts, + launchCommandHint ) - delete baseEnv.ORCA_OPENCODE_AGENT - if (openCodeAgent) { - // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. - const openCodeStatusService = - openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService - baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent - // WSL owns its config writes; only the guest overlay may enter a WSL pane. - if (!opts.isWsl) { - Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - } - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir - } else { - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - } if (opts.agentStatusHooksEnabled) { if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) @@ -343,5 +291,8 @@ export function buildPtyHostEnv( // process.env when baseEnv carries none, which is the daemon path's normal shape. stripLegacyTerminalShimEnv(baseEnv, process.platform) + if (!opts.isWsl) { + ensureOpenCodeStartupPromptForLaunch(baseEnv) + } return baseEnv } diff --git a/src/main/ipc/pty/host-env/opencode-config.ts b/src/main/ipc/pty/host-env/opencode-config.ts new file mode 100644 index 00000000000..88dafabc88e --- /dev/null +++ b/src/main/ipc/pty/host-env/opencode-config.ts @@ -0,0 +1,84 @@ +import { + OPENCODE_CONFIG_DIR_ENV_KEYS, + isOpenCodeLegacySharedConfigDir +} from '../../../opencode/legacy-shared-config-dir' +import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent' +import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import type { BuildPtyHostEnvOptions } from './types' + +type OpenCodeSourceConfig = { + inheritedEnv: NodeJS.ProcessEnv + directory: string | undefined +} + +export function captureOpenCodeSourceConfig( + env: Record, + userDataPath: string +): OpenCodeSourceConfig { + const isLegacyDirectory = (dir: string | undefined): boolean => + isOpenCodeLegacySharedConfigDir(dir, userDataPath) + const inheritedEnv: NodeJS.ProcessEnv = {} + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + if (isLegacyDirectory(env[key])) { + delete env[key] + } + if (!isLegacyDirectory(process.env[key])) { + inheritedEnv[key] = process.env[key] + } + } + // A daemon or sibling shell can retain a retired path that main no longer sees. + openCodeHookService.refreshLegacySharedPlugin() + openCode2HookService.refreshLegacySharedPlugin() + const directory = resolveOpenCodeSourceConfigDir(env, inheritedEnv) + return { inheritedEnv, directory: isLegacyDirectory(directory) ? undefined : directory } +} + +export function applyOpenCodeStatusPluginEnv( + id: string, + env: Record, + config: OpenCodeSourceConfig, + options: Pick< + BuildPtyHostEnvOptions, + 'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl' + >, + command: string | undefined +): 'opencode' | 'opencode2' | null { + const agent = selectOpenCodeHookAgent( + options.launchAgent, + command, + (candidate) => + options.agentStatusHooksEnabled && isTuiAgentEnabled(candidate, options.disabledTuiAgents) + ) + restoreOrStripOverlayEnv( + env, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + config.inheritedEnv + ) + delete env.ORCA_OPENCODE_AGENT + if (!agent) { + return null + } + const service = agent === 'opencode2' ? openCode2HookService : openCodeHookService + env.ORCA_OPENCODE_AGENT = agent + // WSL owns its config writes; only the guest overlay may enter a WSL pane. + if (!options.isWsl) { + Object.assign(env, service.buildPtyEnv(id, config.directory)) + } + if (env.OPENCODE_CONFIG_DIR) { + // Shell startup can re-export the default; preserve this pane's overlay and original source. + env.ORCA_OPENCODE_CONFIG_DIR = env.OPENCODE_CONFIG_DIR + if (config.directory) { + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = config.directory + } else { + delete env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + return agent +} diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index a52a9d70360..ddae2771aba 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -77,6 +77,28 @@ afterEach(() => { }) describe('OpenCode installation uses the current enabled agents', () => { + it('refuses spawn if a prepared startup intent loses its owned installer', () => { + mkdirSync(fixture.userData, { recursive: true }) + writeFileSync( + join(fixture.userData, 'opencode-startup-prompt-overlays'), + 'blocked fixture root' + ) + expect(() => + buildPtyHostEnv( + 'owned-launch', + { + OPENCODE_CONFIG_DIR: custom, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce', + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief' + }, + { ...options, agentStatusHooksEnabled: false } + ) + ).toThrow('launch was canceled') + expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}') + expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin') + }) + const combinations = [ { disabled: [], fallback: 'opencode' }, { disabled: ['opencode'], fallback: 'opencode2' }, diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 77491c9fb11..24df59c1201 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx)) +} + +async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) if (ctx.nativeWindowsConptySpawn) { @@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise commitReservedRuntimePtySpawn(ctx)) +} + +async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) @@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } + claimSshPaneLease({ store: ctx.deps.store, connectionId: args.connectionId, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index d413c0556f0..26c3856b45e 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -35,6 +35,7 @@ import { import type { RuntimePtySpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native-account-launch' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -85,20 +86,12 @@ export async function buildRuntimePtySpawnOptions( ? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData')) : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedAgentSessionStampEnvKeysToDelete(ctx.env) - ) - if (ctx.skipCodexHomeEnv) { - ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( - ctx.spawnOptions.envToDelete, - CODEX_HOME_ENV_KEYS - ) - } else if (ctx.stripInheritedOrcaCodexHome) { + getInheritedAgentSessionStampEnvKeysToDelete(ctx.env), + ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the daemon owns a persistent inherited environment that may // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. - ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(ctx.spawnOptions.envToDelete, [ - 'ORCA_CODEX_HOME' - ]) - } + ctx.stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] + ) if (ctx.codexResumeHomeSelected) { ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } @@ -111,6 +104,21 @@ export async function buildRuntimePtySpawnOptions( env: ctx.env, envToDelete: ctx.spawnOptions.envToDelete }) + const openCodeLaunch = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.env, + envToDelete: (ctx.spawnOptions.envToDelete ??= []), + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) + ctx.env = openCodeLaunch.env + ctx.launchCommand = openCodeLaunch.command + ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ command: ctx.launchCommand, diff --git a/src/main/jcode/hook-config.test.ts b/src/main/jcode/hook-config.test.ts index b82e6a417b9..17dbcb379c2 100644 --- a/src/main/jcode/hook-config.test.ts +++ b/src/main/jcode/hook-config.test.ts @@ -132,9 +132,6 @@ turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh }) it('repoints a managed entry left behind by a copied home or a platform switch', () => { - // Why: isManaged matches any agent-hooks/jcode-hook path, but getStatus demands - // the exact script path — a stale entry stuck the install on `partial` forever - // with no Orca action able to repair it. const stale = '/Users/old/.orca/agent-hooks/jcode-hook.sh' const source = `[hooks]\nturn_end = ${tomlQuoteString(stale)}\n` const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') diff --git a/src/main/jcode/hook-service.test.ts b/src/main/jcode/hook-service.test.ts index 3a87aa4ef6a..f86fe52444c 100644 --- a/src/main/jcode/hook-service.test.ts +++ b/src/main/jcode/hook-service.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -17,6 +17,7 @@ import { JcodeHookService } from './hook-service' import { getJcodeConfigPath, getJcodeManagedCommand, + getJcodeManagedScriptFileName, getJcodeManagedScriptPath, JCODE_HOOK_EVENTS } from './hook-settings' @@ -72,6 +73,123 @@ describe('JcodeHookService', () => { expect(script).toContain('payload="$JCODE_HOOK_PAYLOAD"') }) + it('reports a missing managed script and repairs it without changing config', () => { + const service = new JcodeHookService() + service.install() + const config = readFileSync(getJcodeConfigPath(), 'utf8') + rmSync(getJcodeManagedScriptPath()) + + expect(service.getStatus()).toMatchObject({ + state: 'partial', + managedHooksPresent: true, + detail: 'Managed hook script missing' + }) + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config) + expect(service.install().state).toBe('installed') + expect(readFileSync(getJcodeManagedScriptPath(), 'utf8')).toContain('/hook/jcode') + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config) + }) + + it('reports missing scripts alongside incomplete event coverage and user hooks', () => { + const service = new JcodeHookService() + service.install() + writeFileSync( + getJcodeConfigPath(), + `[hooks]\nsession_start = ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}\nturn_end = "~/bin/my-turn-notify"\n`, + 'utf8' + ) + rmSync(getJcodeManagedScriptPath()) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Managed hook script missing') + expect(status.detail).toContain( + 'Managed hook missing for events: turn_start, pre_tool, post_tool, session_end' + ) + expect(status.detail).toContain('User-owned hooks kept for events: turn_end') + expect(service.install().state).toBe('partial') + expect(service.getStatus().detail).not.toContain('script missing') + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toContain( + 'turn_end = "~/bin/my-turn-notify"' + ) + }) + + it.each([ + '/Users/previous/.orca/agent-hooks/jcode-hook.sh', + 'C:\\Users\\previous\\.orca\\agent-hooks\\jcode-hook.cmd' + ])('recognizes a stale managed command %s without a local script', (stalePath) => { + const service = new JcodeHookService() + const configPath = getJcodeConfigPath() + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `[hooks]\nturn_end = ${tomlQuoteString(getJcodeManagedCommand(stalePath))}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toContain('Managed hook command outdated for events: turn_end') + expect(status.detail).toContain('Managed hook script missing') + expect(status.detail).not.toContain('User-owned') + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath, 'utf8')).not.toContain('previous') + }) + + it('does not report complete stale event coverage as installed when the current script exists', () => { + const service = new JcodeHookService() + service.install() + const staleCommand = getJcodeManagedCommand('/Users/previous/.orca/agent-hooks/jcode-hook.sh') + writeFileSync( + getJcodeConfigPath(), + `[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(staleCommand)}`).join('\n')}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toBe( + `Managed hook command outdated for events: ${JCODE_HOOK_EVENTS.join(', ')}` + ) + expect(service.install().state).toBe('installed') + }) + + it('reports legacy unquoted commands as outdated even when the script exists', () => { + const service = new JcodeHookService() + service.install() + writeFileSync( + getJcodeConfigPath(), + `[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(getJcodeManagedScriptPath())}`).join('\n')}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Managed hook command outdated for events:') + expect(status.detail).not.toContain('script missing') + expect(status.detail).not.toContain('User-owned') + expect(service.install().state).toBe('installed') + }) + + it('does not mistake a user command mentioning the managed script in a comment for Orca ownership', () => { + const configPath = getJcodeConfigPath() + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `[hooks]\nturn_end = "~/bin/my-turn-notify" # replaces agent-hooks/${getJcodeManagedScriptFileName()}\n`, + 'utf8' + ) + + const status = new JcodeHookService().getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(false) + expect(status.detail).toContain('User-owned hooks kept for events: turn_end') + expect(status.detail).not.toContain('command outdated') + expect(status.detail).not.toContain('script missing') + }) + it('preserves unrelated config tables when installing hooks', () => { const configPath = getJcodeConfigPath() mkdirSync(dirname(configPath), { recursive: true }) @@ -107,6 +225,8 @@ describe('JcodeHookService', () => { expect(before).toContain('turn_end') const status = new JcodeHookService().remove() expect(status.state).toBe('not_installed') + expect(status.detail).toBeNull() + expect(existsSync(getJcodeManagedScriptPath())).toBe(true) const after = readFileSync(getJcodeConfigPath(), 'utf8') expect(after).not.toContain(getJcodeManagedScriptPath()) }) diff --git a/src/main/jcode/hook-service.ts b/src/main/jcode/hook-service.ts index ebe41a2dc36..eff119f91d5 100644 --- a/src/main/jcode/hook-service.ts +++ b/src/main/jcode/hook-service.ts @@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildWindowsAgentHookPostCommand, + createManagedCommandMatcher, writeManagedScript } from '../agent-hooks/installer-utils' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' @@ -128,33 +129,42 @@ export class JcodeHookService { } const scriptPresent = existsSync(scriptPath) const managedCommand = getJcodeManagedCommand(scriptPath) + const isManaged = createManagedCommandMatcher(getJcodeManagedScriptFileName()) const missing: string[] = [] + const outdated: string[] = [] const userOwned: string[] = [] let managedCount = 0 for (const event of JCODE_HOOK_EVENTS) { const value = table[event] - // Why both forms: installs before the quoting fix stored the bare path, and - // install() repoints those — reporting them user-owned would hide the repair. - if (value === managedCommand || value === scriptPath) { + if (value === managedCommand) { managedCount += 1 + } else if (isManaged(value)) { + outdated.push(event) } else if (value === undefined) { missing.push(event) } else { userOwned.push(event) } } - const managedHooksPresent = managedCount > 0 || scriptPresent + const hasManagedEntries = managedCount > 0 || outdated.length > 0 + const managedHooksPresent = hasManagedEntries || scriptPresent let state: AgentHookInstallState let detail: string | null - if (missing.length === 0 && userOwned.length === 0) { + if (managedCount === JCODE_HOOK_EVENTS.length && scriptPresent) { state = 'installed' detail = null - } else if (managedCount === 0 && missing.length === JCODE_HOOK_EVENTS.length) { + } else if (!hasManagedEntries && missing.length === JCODE_HOOK_EVENTS.length) { state = 'not_installed' detail = null } else { state = 'partial' const parts: string[] = [] + if (hasManagedEntries && !scriptPresent) { + parts.push('Managed hook script missing') + } + if (outdated.length > 0) { + parts.push(`Managed hook command outdated for events: ${outdated.join(', ')}`) + } if (missing.length > 0) { parts.push(`Managed hook missing for events: ${missing.join(', ')}`) } diff --git a/src/main/macos-update-running-instances.integration.test.ts b/src/main/macos-update-running-instances.integration.test.ts new file mode 100644 index 00000000000..afc5c3d7e66 --- /dev/null +++ b/src/main/macos-update-running-instances.integration.test.ts @@ -0,0 +1,97 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { once } from 'node:events' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { expect, it, vi } from 'vitest' +import { runProcess, spawnProcess } from '../shared/child-process/run-process' +import { getMacUpdateRunningInstances } from './macos-update-running-instances' + +const APPLICATION_SOURCE = ` +#import +#import +int main(int argc, const char *argv[]) { + if (argc > 1) { sleep(30); return 0; } + @autoreleasepool { + NSApplication *app = [NSApplication sharedApplication]; + [app setActivationPolicy:NSApplicationActivationPolicyProhibited]; + [app run]; + } + return 0; +} +` + +it.runIf(process.platform === 'darwin')( + 'matches ShipIt with registered sibling apps, excluding same-executable workers and other bundle copies', + async () => { + const root = mkdtempSync(path.join(tmpdir(), 'orca-update-instances-')) + const bundle = path.join(root, 'Orca Test.app') + const executable = path.join(bundle, 'Contents', 'MacOS', 'Orca Test') + mkdirSync(path.dirname(executable), { recursive: true }) + const sourcePath = path.join(root, 'application.m') + writeFileSync(sourcePath, APPLICATION_SOURCE) + writeFileSync( + path.join(bundle, 'Contents', 'Info.plist'), + ` + CFBundleIdentifiercom.stablyai.${path.basename(root)} + CFBundleExecutableOrca Test + CFBundlePackageTypeAPPL + ` + ) + const children: ReturnType[] = [] + const closed: Promise[] = [] + try { + const compilation = await runProcess({ + program: '/usr/bin/clang', + args: ['-framework', 'AppKit', sourcePath, '-o', executable], + timeoutMs: 15_000 + }) + expect(compilation.code, compilation.stderr).toBe(0) + const otherBundle = path.join(root, 'Other Orca.app') + cpSync(bundle, otherBundle, { recursive: true }) + for (const [program, args] of [ + [executable, []], + [executable, []], + [executable, ['--worker']], + [path.join(otherBundle, 'Contents', 'MacOS', 'Orca Test'), []] + ] satisfies [string, string[]][]) { + const child = spawnProcess({ + program, + args, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + }) + children.push(child) + closed.push(once(child, 'close')) + } + const [self, sibling, worker, otherCopy] = children + await vi.waitFor( + async () => { + expect(self.pid).toBeTypeOf('number') + expect(sibling.pid).toBeTypeOf('number') + expect(await getMacUpdateRunningInstances(executable, self.pid)).toEqual([sibling.pid]) + expect( + await getMacUpdateRunningInstances( + path.join(otherBundle, 'Contents', 'MacOS', 'Orca Test'), + 0 + ) + ).toEqual([otherCopy.pid]) + const workerListing = await runProcess({ + program: '/bin/ps', + args: ['-p', String(worker.pid), '-ww', '-o', 'comm='] + }) + expect(workerListing.stdout.trim()).toBe(executable) + }, + { timeout: 5000 } + ) + sibling.kill('SIGTERM') + await closed[1] + expect(await getMacUpdateRunningInstances(executable, self.pid)).toEqual([]) + } finally { + for (const child of children) { + child.kill('SIGTERM') + } + await Promise.all(closed) + rmSync(root, { recursive: true, force: true }) + } + }, + 25_000 +) diff --git a/src/main/macos-update-running-instances.test.ts b/src/main/macos-update-running-instances.test.ts new file mode 100644 index 00000000000..f6af6f04f53 --- /dev/null +++ b/src/main/macos-update-running-instances.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getMacUpdateRunningInstances, + parseMacUpdateRunningInstances +} from './macos-update-running-instances' + +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) +vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) + +const executable = '/Applications/Orca Test.app/Contents/MacOS/Orca Test' + +describe('macOS update running instances', () => { + beforeEach(() => { + runProcessMock.mockReset() + }) + + it('excludes the current process from the native registry result', () => { + expect(parseMacUpdateRunningInstances('[100,101,102]\n', 100)).toEqual([101, 102]) + expect(parseMacUpdateRunningInstances('[]\n', 100)).toEqual([]) + }) + + it.each([ + 'not JSON', + '', + '{}', + 'null', + '[0]', + '[-1]', + '[1.5]', + '["101"]', + '[null]', + '[9007199254740992]' + ])('rejects invalid registry output: %s', (listing) => { + expect(() => parseMacUpdateRunningInstances(listing, 100)).toThrow() + }) + + it.runIf(process.platform === 'darwin')( + 'passes the bundle path as an argument rather than executable script', + async () => { + const unusualExecutable = '/Applications/Orca "Test" $HOME.app/Contents/MacOS/Orca Test' + runProcessMock.mockResolvedValue({ code: 0, stdout: '[]', timedOut: false }) + await expect(getMacUpdateRunningInstances(unusualExecutable)).resolves.toEqual([]) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + args: [ + '-l', + 'JavaScript', + '-e', + expect.not.stringContaining('$HOME'), + '/Applications/Orca "Test" $HOME.app' + ] + }) + ) + } + ) + + it('skips development runtimes without probing the host', async () => { + expect(await getMacUpdateRunningInstances('/usr/local/bin/node')).toEqual([]) + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it.each(['linux', 'win32'])('does not probe on %s', async (platform) => { + vi.stubGlobal('process', { ...process, platform }) + try { + expect(await getMacUpdateRunningInstances(executable)).toEqual([]) + expect(runProcessMock).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) + + it.runIf(process.platform === 'darwin')( + 'uses the bounded native application registry query and preserves paths with spaces', + async () => { + runProcessMock.mockResolvedValue({ + code: 0, + stdout: '[100,101]\n', + timedOut: false + }) + expect(await getMacUpdateRunningInstances(executable, 100)).toEqual([101]) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/usr/bin/osascript', + args: [ + '-l', + 'JavaScript', + '-e', + expect.stringContaining('runningApplicationsWithBundleIdentifier'), + '/Applications/Orca Test.app' + ], + timeoutMs: 5000, + killOnOutputLimit: true + }) + ) + } + ) + + it.runIf(process.platform === 'darwin').each([ + { code: 1, timedOut: false }, + { code: null, timedOut: true }, + { code: 0, timedOut: false, outputTruncated: true } + ])('fails closed for incomplete query results: %j', async (result) => { + runProcessMock.mockResolvedValue({ stdout: '', ...result }) + await expect(getMacUpdateRunningInstances(executable)).rejects.toThrow('Could not check') + }) +}) diff --git a/src/main/macos-update-running-instances.ts b/src/main/macos-update-running-instances.ts new file mode 100644 index 00000000000..1ce757d30f5 --- /dev/null +++ b/src/main/macos-update-running-instances.ts @@ -0,0 +1,53 @@ +import path from 'node:path' +import { runProcess } from '../shared/child-process/run-process' + +const RUNNING_INSTANCES_SCRIPT = `function run(argv) { + ObjC.import('AppKit'); + const bundle = $.NSBundle.bundleWithPath(argv[0]); + const identifier = ObjC.unwrap(bundle.bundleIdentifier); + if (typeof identifier !== 'string' || !identifier) throw new Error('Missing bundle identifier'); + const target = $.NSURL.fileURLWithPath(argv[0]).URLByStandardizingPath; + const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(identifier); + const pids = []; + for (let i = 0; i < apps.count; i++) { + const app = apps.objectAtIndex(i); + if (app.bundleURL && app.bundleURL.URLByStandardizingPath.isEqual(target)) { + pids.push(Number(app.processIdentifier)); + } + } + return JSON.stringify(pids); +}` + +/** Squirrel waits for every main application process from the target bundle. */ +export async function getMacUpdateRunningInstances( + executable = process.execPath, + currentPid = process.pid +): Promise { + if (process.platform !== 'darwin' || !executable.includes('.app/Contents/MacOS/')) { + return [] + } + const bundlePath = path.dirname(path.dirname(path.dirname(executable))) + // Match ShipIt's registry query; run-as-node workers share the executable but do not block it. + const result = await runProcess({ + program: '/usr/bin/osascript', + args: ['-l', 'JavaScript', '-e', RUNNING_INSTANCES_SCRIPT, bundlePath], + timeoutMs: 5_000, + maxOutputBytes: 2 * 1024 * 1024, + killOnOutputLimit: true + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error('Could not check running Orca instances') + } + return parseMacUpdateRunningInstances(result.stdout, currentPid) +} + +export function parseMacUpdateRunningInstances(listing: string, currentPid: number): number[] { + const pids: unknown = JSON.parse(listing) + if ( + !Array.isArray(pids) || + !pids.every((pid: unknown) => typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) + ) { + throw new Error('Invalid macOS application listing') + } + return pids.filter((pid: number) => pid !== currentPid) +} diff --git a/src/main/managed-data-accounts/profile-removal-recovery.test.ts b/src/main/managed-data-accounts/profile-removal-recovery.test.ts index ec3035ba634..3cba71460ef 100644 --- a/src/main/managed-data-accounts/profile-removal-recovery.test.ts +++ b/src/main/managed-data-accounts/profile-removal-recovery.test.ts @@ -5,6 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, symlinkSync, @@ -63,6 +64,43 @@ async function finishStartup(service: ManagedDataAccountService) { } describe('interrupted managed account removal recovery', () => { + it.each(['devin', 'opencode'] as const)( + 'quarantines the canonical %s directory registered with an uppercase UUID', + async (provider) => { + const fixture = interruptedRemoval(provider) + const id = fixture.id.toUpperCase() + const registered = { + accounts: [{ ...fixture.before.accounts[0], id }], + activeAccountId: id + } + expect(writeSecureFile(fixture.metadataPath, JSON.stringify(registered))).toBe(true) + const cleanup = vi.fn(() => { + throw new Error('cleanup deferred') + }) + const service = new ManagedDataAccountService(storage, cleanup) + + await expect(service.remove(provider, id)).resolves.toEqual({ + accounts: [], + activeAccountId: null + }) + + const pendingRoot = join(fixture.providerRoot, '.pending-delete') + const pendingDirectory = join(pendingRoot, fixture.id) + expect(cleanup).toHaveBeenCalledWith(pendingDirectory) + expect(readdirSync(pendingRoot)).toEqual([fixture.id]) + expect(existsSync(fixture.directory)).toBe(false) + expect(readFileSync(join(pendingDirectory, 'data', 'test-credentials'), 'utf8')).toBe( + 'private-test-only-credential' + ) + expect(existsSync(fixture.rollbackPath)).toBe(false) + + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(existsSync(pendingDirectory)).toBe(false) + expect(restarted.list(provider)).toEqual({ accounts: [], activeAccountId: null }) + } + ) + it.each(['devin', 'opencode'] as const)( 'quarantines a committed %s removal left before quarantine on restart', async (provider) => { @@ -77,39 +115,48 @@ describe('interrupted managed account removal recovery', () => { } ) - it('recovers after both quarantine and metadata rollback fail without the removed id', async () => { - const source = join(root, 'source') - mkdirSync(join(source, 'devin'), { recursive: true }) - writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only"') - const service = new ManagedDataAccountService(storage) - const before = await service.add('devin', source, 'Work') - const id = before.accounts[0].id - const directory = join(storage, 'devin', id) - const credentialsPath = join(directory, 'data', 'devin', 'credentials.toml') - const metadataPath = join(storage, 'devin', 'accounts.json') - const rollbackPath = `${metadataPath}.${id}.rollback` - const credentials = readFileSync(credentialsPath) - const original = readFileSync(metadataPath) - const rename = fileSystem.renameSync - const failingRename = vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => { - if (from === directory || from === rollbackPath) { - throw new Error('injected removal and rollback lock') + it.each(['lowercase', 'uppercase'] as const)( + 'recovers after quarantine and rollback fail for a %s registered UUID', + async (spelling) => { + const source = join(root, 'source') + mkdirSync(join(source, 'devin'), { recursive: true }) + writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only"') + const service = new ManagedDataAccountService(storage) + const added = await service.add('devin', source, 'Work') + const directoryId = added.accounts[0].id + const id = spelling === 'uppercase' ? directoryId.toUpperCase() : directoryId + const before = { + accounts: [{ ...added.accounts[0], id }], + activeAccountId: id } - return rename(from, to) - }) - await expect(service.remove('devin', id)).rejects.toBeInstanceOf(AggregateError) - expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null }) - expect(readFileSync(credentialsPath)).toEqual(credentials) - expect(readFileSync(rollbackPath)).toEqual(original) - failingRename.mockRestore() + const directory = join(storage, 'devin', directoryId) + const credentialsPath = join(directory, 'data', 'devin', 'credentials.toml') + const metadataPath = join(storage, 'devin', 'accounts.json') + const rollbackPath = `${metadataPath}.${directoryId}.rollback` + expect(writeSecureFile(metadataPath, JSON.stringify(before))).toBe(true) + const credentials = readFileSync(credentialsPath) + const original = readFileSync(metadataPath) + const rename = fileSystem.renameSync + const failingRename = vi.spyOn(fileSystem, 'renameSync').mockImplementation((from, to) => { + if (from === directory || from === rollbackPath) { + throw new Error('injected removal and rollback lock') + } + return rename(from, to) + }) + await expect(service.remove('devin', id)).rejects.toBeInstanceOf(AggregateError) + expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + expect(readFileSync(credentialsPath)).toEqual(credentials) + expect(readFileSync(rollbackPath)).toEqual(original) + failingRename.mockRestore() - const restarted = new ManagedDataAccountService(storage) - await finishStartup(restarted) - expect(existsSync(directory)).toBe(false) - expect(existsSync(rollbackPath)).toBe(false) - expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null }) - expect(readFileSync(join(source, 'devin', 'credentials.toml'), 'utf8')).toContain('test-only') - }) + const restarted = new ManagedDataAccountService(storage) + await finishStartup(restarted) + expect(existsSync(directory)).toBe(false) + expect(existsSync(rollbackPath)).toBe(false) + expect(restarted.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + expect(readFileSync(join(source, 'devin', 'credentials.toml'), 'utf8')).toContain('test-only') + } + ) it('retains recovery evidence when quarantine is locked and continues other removals', async () => { const locked = interruptedRemoval() diff --git a/src/main/managed-data-accounts/profile-removal.ts b/src/main/managed-data-accounts/profile-removal.ts index 9cd83379054..46f6ea1c312 100644 --- a/src/main/managed-data-accounts/profile-removal.ts +++ b/src/main/managed-data-accounts/profile-removal.ts @@ -27,10 +27,11 @@ export class ManagedDataAccountProfileRemoval { if (!z.uuid().safeParse(accountId).success) { throw new Error('Managed account not found.') } - const directory = join(this.root, provider, accountId) - const pendingDirectory = join(this.root, provider, '.pending-delete', accountId) + const pathId = accountId.toLowerCase() + const directory = join(this.root, provider, pathId) + const pendingDirectory = join(this.root, provider, '.pending-delete', pathId) const metadataPath = join(this.root, provider, 'accounts.json') - const rollbackPath = `${metadataPath}.${accountId}.rollback` + const rollbackPath = `${metadataPath}.${pathId}.rollback` if (!state.accounts.some((account) => account.id === accountId)) { if (state.accounts.some((account) => account.id.toLowerCase() === accountId.toLowerCase())) { throw new Error('Managed account not found.') diff --git a/src/main/managed-data-accounts/service-enrollment-persistence.test.ts b/src/main/managed-data-accounts/service-enrollment-persistence.test.ts new file mode 100644 index 00000000000..9375071d2f1 --- /dev/null +++ b/src/main/managed-data-accounts/service-enrollment-persistence.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as filesystem from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' +import * as secureFile from '../../shared/secure-file' +import { ManagedDataAccountService } from './service' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual } +}) + +let root: string +let source: string +let service: ManagedDataAccountService + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-enrollment-persistence-')) + source = join(root, 'source') + mkdirSync(join(source, 'devin'), { recursive: true }) + writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only-key"\n') + mkdirSync(join(source, 'opencode'), { recursive: true }) + const database = new SyncDatabase(join(source, 'opencode', 'opencode.db')) + database.exec( + 'CREATE TABLE session_v2 (id TEXT); CREATE TABLE credential (integration_id TEXT, value TEXT)' + ) + database + .prepare('INSERT INTO credential VALUES (?, ?)') + .run('google', '{"type":"key","key":"test-only-key"}') + database.close() + service = new ManagedDataAccountService(join(root, 'managed')) +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +describe.each(['opencode', 'devin'] as const)('committed %s enrollment', (provider) => { + it.each(['after write', 'unrestricted'])( + 'keeps captured credentials when metadata persistence reports %s failure', + async (failure) => { + const metadata = join(root, 'managed', provider, 'accounts.json') + const write = secureFile.writeSecureFile + vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => { + const result = write(...args) + if (args[0] !== metadata) { + return result + } + if (failure === 'unrestricted') { + return false + } + throw new Error('post-publication write failure') + }) + await expect(service.add(provider, source, 'Work')).rejects.toThrow( + failure === 'unrestricted' ? 'metadata permissions' : 'post-publication' + ) + const state = service.list(provider) + expect(state.accounts).toHaveLength(1) + expect(state.activeAccountId).toBe(state.accounts[0].id) + const directory = join(root, 'managed', provider, state.accounts[0].id) + expect(existsSync(directory)).toBe(true) + expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBe(join(directory, 'data')) + } + ) + + it('cleans an unregistered profile when metadata fails before publication', async () => { + const metadata = join(root, 'managed', provider, 'accounts.json') + const write = secureFile.writeSecureFile + vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => { + if (args[0] === metadata) { + throw new Error('pre-publication write failure') + } + return write(...args) + }) + await expect(service.add(provider, source, 'Work')).rejects.toThrow('pre-publication') + expect(service.list(provider)).toEqual({ accounts: [], activeAccountId: null }) + expect(readdirSync(join(root, 'managed', provider))).toEqual([]) + }) + + it('keeps credentials when published metadata is unreadable', async () => { + const metadata = join(root, 'managed', provider, 'accounts.json') + const write = secureFile.writeSecureFile + let published = '' + vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => { + const result = write(...args) + if (args[0] !== metadata) { + return result + } + published = readFileSync(metadata, 'utf8') + writeFileSync(metadata, '{') + throw new Error('post-publication metadata unreadable') + }) + await expect(service.add(provider, source, 'Work')).rejects.toThrow('metadata unreadable') + expect( + readdirSync(join(root, 'managed', provider)).filter((name) => name !== 'accounts.json') + ).toHaveLength(1) + writeFileSync(metadata, published) + expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBeTruthy() + }) + + it('keeps credentials when metadata existence cannot be checked', async () => { + const metadata = join(root, 'managed', provider, 'accounts.json') + const write = secureFile.writeSecureFile + const exists = filesystem.existsSync + const stat = filesystem.lstatSync + let inaccessible = false + vi.spyOn(filesystem, 'existsSync').mockImplementation((path) => + inaccessible && path === metadata ? false : exists(path) + ) + vi.spyOn(filesystem, 'lstatSync').mockImplementation((...args) => { + if (inaccessible && args[0] === metadata) { + throw Object.assign(new Error('Metadata access denied'), { code: 'EACCES' }) + } + return stat(...args) + }) + vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => { + const result = write(...args) + if (args[0] !== metadata) { + return result + } + inaccessible = true + return false + }) + await expect(service.add(provider, source, 'Work')).rejects.toThrow('metadata permissions') + inaccessible = false + const state = service.list(provider) + expect(exists(join(root, 'managed', provider, state.accounts[0].id))).toBe(true) + expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBeTruthy() + }) + + it('preserves a profile registered with another UUID case', async () => { + const metadata = join(root, 'managed', provider, 'accounts.json') + const write = secureFile.writeSecureFile + const writer = vi.spyOn(secureFile, 'writeSecureFile').mockImplementation((...args) => { + const result = write(...args) + if (args[0] !== metadata) { + return result + } + const state = service.list(provider) + writeFileSync( + metadata, + JSON.stringify({ + ...state, + accounts: state.accounts.map((account) => ({ ...account, id: account.id.toUpperCase() })), + activeAccountId: state.activeAccountId?.toUpperCase() + }) + ) + throw new Error('post-publication UUID case change') + }) + await expect(service.add(provider, source, 'Work')).rejects.toThrow('UUID case change') + writer.mockRestore() + const registered = service.list(provider).accounts[0] + const dataHome = join(root, 'managed', provider, registered.id.toLowerCase(), 'data') + expect(existsSync(dataHome)).toBe(true) + expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBe(dataHome) + expect( + service.transcriptEnvironments(provider).map((environment) => environment.XDG_DATA_HOME) + ).toEqual([dataHome]) + expect((await service.select(provider, registered.id.toLowerCase())).activeAccountId).toBe( + registered.id + ) + expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBe(dataHome) + expect((await service.select(provider, registered.id.toUpperCase())).activeAccountId).toBe( + registered.id + ) + expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBe(dataHome) + }) + + it('selects the registered UUID spelling and searches its transcript first', async () => { + const personal = (await service.add(provider, source, 'Personal')).accounts[0] + const work = (await service.add(provider, source, 'Work')).accounts[1] + const selected = await service.select(provider, work.id.toUpperCase()) + expect(selected.activeAccountId).toBe(work.id) + expect(service.list(provider).activeAccountId).toBe(work.id) + expect( + service.transcriptEnvironments(provider).map((environment) => environment.XDG_DATA_HOME) + ).toEqual( + [work, personal].map((account) => + join(root, 'managed', provider, account.id.toLowerCase(), 'data') + ) + ) + }) + + it('isolates a throwing listener after enrollment has committed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + service.onChanged(() => { + throw new Error('private-listener-detail') + }) + const healthy = vi.fn() + service.onChanged(healthy) + const state = await service.add(provider, source, 'Work') + expect(service.list(provider)).toEqual(state) + expect(service.launchEnvironment(provider).XDG_DATA_HOME).toBeTruthy() + expect(healthy).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledOnce() + expect(warn.mock.calls.flat().join(' ')).not.toContain('private-listener-detail') + }) +}) diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts index 5ab7f12d04c..bc96800a5aa 100644 --- a/src/main/managed-data-accounts/service.test.ts +++ b/src/main/managed-data-accounts/service.test.ts @@ -491,15 +491,18 @@ describe('managed data accounts', () => { expect(service.transcriptEnvironments('devin')).toEqual([secondEnvironment]) }) - it('rejects a credential symlink without touching its target', async () => { - const original = join(source, 'devin', 'credentials.toml') - const target = join(root, 'private.toml') - writeFileSync(target, readFileSync(original)) - rmSync(original) - symlinkSync(target, original) - await expect(service.add('devin', source, 'Work')).rejects.toThrow('regular file') - expect(readFileSync(target, 'utf8')).toContain('test-only-key') - }) + it.skipIf(process.platform === 'win32')( + 'rejects a credential symlink without touching its target', + async () => { + const original = join(source, 'devin', 'credentials.toml') + const target = join(root, 'private.toml') + writeFileSync(target, readFileSync(original)) + rmSync(original) + symlinkSync(target, original) + await expect(service.add('devin', source, 'Work')).rejects.toThrow('regular file') + expect(readFileSync(target, 'utf8')).toContain('test-only-key') + } + ) it('keeps credential parse errors out of RPC messages', async () => { writeFileSync( diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts index 5ebf2ae91f2..749fb023876 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -69,8 +69,7 @@ export class ManagedDataAccountService { if (!existsSync(path)) { return { accounts: [], activeAccountId: null } } - this.assertOwned(path) - return stateSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) + return this.readState(path) } add( @@ -98,7 +97,24 @@ export class ManagedDataAccountService { activeAccountId: id }) } catch (error) { - rmSync(directory, { recursive: true, force: true }) + let registered = true + try { + registered = this.readState(join(this.root, provider, 'accounts.json')).accounts.some( + (account) => account.id.toLowerCase() === id.toLowerCase() + ) + } catch (metadataError) { + if ( + metadataError instanceof Error && + 'code' in metadataError && + metadataError.code === 'ENOENT' + ) { + registered = false + } + // Unreadable metadata cannot prove that this profile is unregistered. + } + if (!registered) { + rmSync(directory, { recursive: true, force: true }) + } throw error } }) @@ -110,10 +126,9 @@ export class ManagedDataAccountService { ): Promise { return this.mutate(async () => { const state = this.list(provider) - if (accountId !== null) { - this.requireAccount(provider, accountId) - } - return this.persist(provider, { ...state, activeAccountId: accountId }) + const activeAccountId = + accountId === null ? null : this.requireAccount(provider, accountId).id + return this.persist(provider, { ...state, activeAccountId }) }) } @@ -206,7 +221,7 @@ export class ManagedDataAccountService { provider: ManagedDataAccountProvider, accountId: string ): Record { - const directory = this.requireAccount(provider, accountId) + const { directory } = this.requireAccount(provider, accountId) return { XDG_DATA_HOME: join(directory, 'data'), XDG_STATE_HOME: join(directory, 'state'), @@ -219,13 +234,19 @@ export class ManagedDataAccountService { return () => this.listeners.delete(listener) } - private requireAccount(provider: ManagedDataAccountProvider, id: string): string { - if (!this.list(provider).accounts.some((account) => account.id === id)) { + private requireAccount( + provider: ManagedDataAccountProvider, + id: string + ): { id: string; directory: string } { + const account = this.list(provider).accounts.find( + (registered) => registered.id.toLowerCase() === id.toLowerCase() + ) + if (!account) { throw new Error('Managed account not found.') } - const directory = join(this.root, provider, id) + const directory = join(this.root, provider, account.id.toLowerCase()) this.assertOwned(directory) - return directory + return { id: account.id, directory } } private persist( @@ -237,6 +258,11 @@ export class ManagedDataAccountService { return checked } + private readState(path: string): ManagedDataAccountsState { + this.assertOwned(path) + return stateSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) + } + private writeState( provider: ManagedDataAccountProvider, state: ManagedDataAccountsState @@ -254,7 +280,11 @@ export class ManagedDataAccountService { private notifyChanged(): void { for (const listener of this.listeners) { - listener() + try { + listener() + } catch { + console.warn('[managed-data-accounts] Account change listener failed.') + } } } diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index a66d43697f6..0828740ca6f 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -13,7 +13,7 @@ import { import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' -import type { JournalRowTransactionHook } from './journal-row-writer' +import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { @@ -101,14 +101,18 @@ export class JournalQueuedMessages { return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) } - /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. */ - insert(input: { - messageId: string - body: AgentJournalMessageItem - fingerprint: string - hostInstance: string - carriedFrom?: string - }): Promise { + /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. + * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */ + insert( + input: { + messageId: string + body: AgentJournalMessageItem + fingerprint: string + hostInstance: string + carriedFrom?: string + }, + receipt?: JournalOperationReceipt + ): Promise { const { sessionId } = this.deps let inserted = false return this.transact( @@ -121,14 +125,17 @@ export class JournalQueuedMessages { } inserted = true const { epoch, lastSequence } = this.deps.state() - return insertQueuedMessage(db, { + const row = insertQueuedMessage(db, { ...input, sessionId, queuedAt: { epoch, sequence: lastSequence }, now: this.deps.now() }) + receipt?.write(db) + return row }, - () => inserted + () => inserted, + receipt?.committed ) } @@ -206,15 +213,17 @@ export class JournalQueuedMessages { } /** One standalone draft-table transaction on the journal's queue; one that - * changed rows bumps the revision and notifies after COMMIT. */ + * changed rows bumps the revision and notifies after COMMIT, `adopted` first. */ private transact( run: (db: Database.Database) => JournalWriteResult, - changed: (result: T) => boolean + changed: (result: T) => boolean, + adopted?: () => void ): Promise { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const result = this.deps.database().transaction(run) if (changed(result)) { + adopted?.() this.changeRevision++ this.deps.committed() } diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index b07fda9459b..ca274120a7b 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -11,6 +11,14 @@ import type { JournalWriteBody } from './journal-write-queue' * nothing can interleave inside the transaction. */ export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) => void +/** An operation's ledger answer, committed with the journal write that makes it true: `write` runs + * inside that transaction on the same connection, `committed` synchronously right after its + * COMMIT and never after a rollback. */ +export type JournalOperationReceipt = { + write: (db: Database.Database) => void + committed: () => void +} + export type JournalRowWriterDeps = { sessionId: string now: () => number @@ -35,7 +43,8 @@ export class JournalRowWriter { enqueue( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook + hook?: JournalRowTransactionHook, + receipt?: JournalOperationReceipt ): Promise { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) @@ -46,6 +55,7 @@ export class JournalRowWriter { this.deps.database().transaction((db) => { insertJournalRow(db, this.deps.sessionId, row) hook?.(db, row) + receipt?.write(db) this.runBookkeeping(db, row) }) } catch (error) { @@ -54,7 +64,8 @@ export class JournalRowWriter { } // COMMIT landed, so the row is durable: adopt it before anything that can // fail. Rejecting here instead would leave the next append reusing a - // sequence the table already holds. + // sequence the table already holds. The ledger first: it cannot throw, the fold can. + receipt?.committed() this.deps.commit(row) return row }) @@ -64,9 +75,13 @@ export class JournalRowWriter { * replay uses — all inside one serialized step — answering where the row landed. */ append( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook + hook?: JournalRowTransactionHook, + receipt?: JournalOperationReceipt ): Promise { - return this.enqueue(build, hook).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + return this.enqueue(build, hook, receipt).then((row) => ({ + epoch: row.epoch, + sequence: row.seq + })) } private runBookkeeping(db: Database.Database, row: JournalRow): void { 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 5a4880155de..2349524ccfe 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -63,7 +63,7 @@ import { journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' -import type { JournalRowWriter } from './journal-row-writer' +import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' @@ -340,11 +340,14 @@ export class AgentSessionJournal { input: JournalSubmissionInput, /** Present: this submission is a queued draft's conversion, and the draft's * state transition commits in the SAME transaction — exactly-once consume. */ - consume?: JournalSubmissionConsume + consume?: JournalSubmissionConsume, + /** The send's ledger answer, committed with this row. */ + receipt?: JournalOperationReceipt ): Promise { return this.rowWriter.append( journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), - consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) + consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume), + receipt ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ddef0ed0bb6..9c920b45eb3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -32,6 +32,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { createStoppedClaudeDeadline } from './structured-agent-session-stop-deadline.test-fixture' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -133,6 +134,8 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() + vi.useRealTimers() await adapter.closeAll() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) @@ -214,6 +217,18 @@ function stop(turnId?: string) { return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) } +/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ +const laneDrained = (): Promise => host['tasks'].serialize(SESSION, async () => {}) + +const stopAcrossGrace = createStoppedClaudeDeadline({ + host: () => host, + adapter: () => adapter, + claude: () => claude, + sessionId: SESSION, + stop, + laneDrained +}) + /** How many person's Stop events the journal holds when the child's close begins. */ function stopEventsAtClose(connection: FakeConnection): () => number | undefined { let atClose: number | undefined @@ -231,11 +246,6 @@ function stopEventsAtClose(connection: FakeConnection): () => number | undefined return () => atClose } -/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ -function laneDrained(): Promise { - return host['tasks'].serialize(SESSION, async () => {}) -} - function wrote(connection: FakeConnection, text: string): boolean { return connection.sent.some((message) => JSON.stringify(message).includes(text)) } @@ -369,14 +379,15 @@ it('ends the child once the grace runs out when Claude says nothing after a Stop claude.routes.interrupt = () => ({ still_queued: [], cancelled: [] }) const clientMessageId = await sendUnechoed(connection) - const asked = Date.now() - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect(stopAcrossGrace(connection, 'request-end')).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) await laneDrained() // As before the wait: the send Claude never answered is doubt once its child ends. expect(connection.closed).toBe(true) expect(eventsAtClose()).toBe(1) - expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) expect(await dispatch(clientMessageId)).toMatchObject({ state: 'unknown' }) }, 15_000) @@ -447,13 +458,14 @@ it('ends the child within the grace when Claude never answers the interrupt', as const eventsAtClose = stopEventsAtClose(connection) await openTurn(connection) - const asked = Date.now() - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect(stopAcrossGrace(connection, 'interrupt')).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) await laneDrained() expect(connection.closed).toBe(true) expect(eventsAtClose()).toBe(1) - expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) expect(await turnOutcome()).toBe('cancellation') expect(await statusTexts()).toEqual(['Cancellation requested.']) }, 15_000) @@ -672,7 +684,15 @@ it.each([ await eventually(() => expect(wrote(connection, 'Follow-up.')).toBe(true)) // As the phone sends it: the turn it last saw working. - await expect(stop(ended)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect( + _answer === 'fails' + ? stop(ended) + : stopAcrossGrace( + connection, + interrupt === NEVER_ANSWERS ? 'interrupt' : 'request-end', + ended + ) + ).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) await laneDrained() expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) 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 3fbb96ddaf8..1133342270d 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 @@ -61,7 +61,8 @@ export function sendStructuredAgentSessionTurn( * prompt never set it. */ userSend?: true beforeRun?: () => void - } + }, + arrival?: Parameters[2] ): Promise> { const plan = sendPlan(params) return mutateStructuredAgentSession( @@ -80,7 +81,7 @@ export function sendStructuredAgentSessionTurn( (await plan.run(ctx)) ) }, - sendPreparation(context, params.envelope) + sendPreparation(context, params.envelope, arrival) ) } 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 5bb072f2594..3ed04e1f2fe 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 @@ -4,16 +4,24 @@ // own admission rules by sitting next to the call site. // // Admission is two-phase for a call that brings a `prepareSession`. The ledger's -// answer comes first and places nothing; a call it will admit may then give the -// session an owner, and only after that are the row placed and the lease -// checked — against the lease as it stands once the owner is there. +// answer comes first and places nothing. An id it refuses is answered then, with +// nothing opened; so is a recorded id that settled refused. Any other recorded id +// is answered after the plan's own preparation for a replay (a send's only opens +// the conversation), from the journal, with no admit write. A call the ledger +// admits, or a replay that proves nothing landed, may then give the session an +// owner, and only after that are the row placed and the lease checked — against +// the lease as it stands once the owner is there. import { admitAgentSessionMutation, agentSessionFingerprintConflict, + agentSessionLedgerRefusal, computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionOperationDecision } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionOperationDecision, + AgentSessionOperationRow +} from '../../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { refuse, @@ -36,7 +44,10 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { MutationPlan } from './structured-agent-session-mutation-plans' import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' -import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import { + agentSessionOperationOutcomeUnknown, + resolveAgentSessionReplayOutcome +} from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -103,12 +114,27 @@ export async function admitAndRunAgentSessionMutation( if (!ledger) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } - if (ledger.decision.decision !== 'refused') { - const prepared = await request.prepareSession(ledger.decision.decision, ledger.record) - if (!prepared.ok) { - return prepared + if (ledger.decision.decision === 'refused') { + // Nothing to read, prepare or write: a closed chat or a store that takes no write answers alike. + return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, ledger.decision)) + } + if (ledger.decision.decision === 'replay') { + const answered = await answerRecordedOperation( + request, + request.prepareSession, + ledger.decision.row, + ledger.record, + hostFingerprint + ) + if (answered !== 'rerun') { + return answered } } + const record = request.store.getRecord(envelope.sessionId) ?? ledger.record + const prepared = await request.prepareSession('admit', record) + if (!prepared.ok) { + return prepared + } } const journal = request.journal() if (!journal) { @@ -151,18 +177,9 @@ export async function admitAndRunAgentSessionMutation( const fence = record.lease.runtimeFence const context = turnContext(request, journal, fence) if (admission.decision === 'replay') { - const replay = resolveAgentSessionReplayOutcome({ - operationId: envelope.clientOperationId, - outcome: admission.row.outcome, - reconstruct: () => plan.replay(context, admission.row.outcome), - rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context), - recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState - }) - if (replay.decision === 'refuse') { - return refuseAgentSessionMutation(replay.refusal) - } - if (replay.decision === 'replay') { - return { ok: true, replayed: true, fence, cursor: journal.cursor(), value: replay.value } + const replayed = replayRecordedOperation(request, context, admission.row) + if (replayed !== 'rerun') { + return replayed } // Nothing durable landed, so this id is about to run for the first time. A // refused call leaves its ledger row behind, and replaying past the lease @@ -197,6 +214,85 @@ export async function admitAndRunAgentSessionMutation( : refuseAgentSessionMutation(outcome.refusal) } +/** + * A resend of a recorded id, answered with no admit write: a refusal its first run recorded, with + * nothing opened; otherwise, after the plan's own preparation for a replay, from the conversation + * its run wrote to. `rerun` when nothing durable landed, so the call runs as a first run. + */ +async function answerRecordedOperation( + request: AgentSessionMutationRequest, + prepareSession: NonNullable['prepareSession']>, + row: AgentSessionOperationRow, + record: AgentSessionRecord, + hostFingerprint: string +): Promise | 'rerun'> { + const { plan, envelope } = request + if (row.outcome.status === 'failed') { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: row.outcome, + reconstruct: () => null + }) + if (replay.decision === 'refuse') { + return refuseAgentSessionMutation(replay.refusal) + } + } + const prepared = await prepareSession('replay', record) + if (!prepared.ok) { + return prepared + } + const journal = request.journal() + // Read again after the open: the row as it stands, against the record the open left. + const current = request.store.evaluateMutationOperation({ + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + if (!journal || !current) { + return refuseAgentSessionMutation( + agentSessionOperationOutcomeUnknown(envelope.clientOperationId) + ) + } + if (current.decision.decision === 'refused') { + return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, current.decision)) + } + if (current.decision.decision === 'admit') { + // The row is gone since: the first-run path decides it from scratch. + return 'rerun' + } + const context = turnContext(request, journal, current.record.lease.runtimeFence) + return replayRecordedOperation(request, context, current.decision.row) +} + +/** The recorded answer from the journal, or `rerun` when the plan says nothing durable landed. */ +function replayRecordedOperation( + { plan, envelope }: AgentSessionMutationRequest, + context: AgentSessionTurnContext, + row: AgentSessionOperationRow +): AgentSessionMutationResult | 'rerun' { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: row.outcome, + reconstruct: () => plan.replay(context, row.outcome), + rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context), + recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState + }) + if (replay.decision === 'refuse') { + return refuseAgentSessionMutation(replay.refusal) + } + return replay.decision === 'replay' + ? { + ok: true, + replayed: true, + fence: context.fence, + cursor: context.journal.cursor(), + value: replay.value + } + : 'rerun' +} + /** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */ function admitWithoutLedgerRow( { store, logger }: Pick, 'store' | 'logger'>, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index a81ea5270c8..6a2a6268ae9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -3,7 +3,9 @@ // // The replay half matters more than it looks. The ledger records only that an // operation happened, so the durable answer usually comes back out of the -// journal. Send is fail-closed: admission alone cannot prove non-delivery. +// journal. Send is fail-closed: admission alone cannot prove non-delivery, so +// its success commits with the row that accepts it, and a row still pending is +// one that wrote nothing. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -51,13 +53,23 @@ export type MutationPlan = { conversationWrite?: true /** Still runs, decided from the committed ledger, when its ledger row cannot be written. */ runsWithoutLedgerRow?: true - markUnknownBeforeRun?: boolean run: (ctx: AgentSessionTurnContext) => Promise> replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean recoverUnknownFromDurableState?: boolean - settledOutcome?: (value: TValue) => AgentSessionOperationOutcome -} +} & ( + | { + /** Its success is the row its run writes, so it commits in that row's transaction + * (`AgentSessionTurnContext.operationReceipt`): a row left pending wrote nothing. That + * success is fixed before the value exists, so it records no `settledOutcome`. */ + settlesWithWrite: true + settledOutcome?: never + } + | { + settlesWithWrite?: never + settledOutcome?: (value: TValue) => AgentSessionOperationOutcome + } +) export function sendPlan(params: { envelope: AgentSessionMutationEnvelope @@ -74,7 +86,7 @@ export function sendPlan(params: { method: 'agentSession.send', operationIdScope: 'global', conversationWrite: true, - markUnknownBeforeRun: true, + settlesWithWrite: true, // `delivery` joins the OPERATION fingerprint only; the submission row keeps // the body-only fingerprint the reducer's echo-aliasing recomputes. fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, @@ -104,7 +116,9 @@ export function sendPlan(params: { if (submission) { return { clientMessageId, submission } } - if (outcome.status === 'failed') { + // A pending row wrote nothing, so the send runs for the first time. Succeeded: accepted, + // then a new epoch dropped its row. Unknown: only builds before this one wrote that. + if (outcome.status === 'failed' || outcome.status === 'pending') { return null } const resolvedAt = ctx.now() @@ -141,7 +155,7 @@ export function conversationCommandPlan(params: { return { method: 'agentSession.conversationCommand', conversationWrite: true, - markUnknownBeforeRun: true, + settlesWithWrite: true, fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND }, recoverUnknownFromDurableState: true, run: async (ctx) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 203e31f4ea0..c7901edc1c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -42,27 +42,18 @@ async function context(): Promise { } } -/** Longer than any bookkeeping bound: a refusal must already be answered by then. */ -const SETTLED_WAIT_MS = 2_000 - afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) -it('returns a pre-dispatch refusal without waiting on redundant uncertainty persistence', async () => { +it('rethrows a throw from a plan answered by its write, writing nothing and leaving the row pending', async () => { const ctx = await context() const { store } = hostTestState() - const stalled = Promise.withResolvers() - const refusing = Promise.withResolvers() - const writes = vi - .spyOn(store, 'recordOperationOutcome') - .mockResolvedValueOnce() - .mockImplementation(() => stalled.promise) + const writes = vi.spyOn(store, 'recordOperationOutcome') const refusal = new AgentSessionPreDispatchError('agent_session_restart_work_superseded') - let returned = false vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const result = runSettledAgentSessionMutation({ + const result = await runSettledAgentSessionMutation({ store, operationCallerKey: 'test', envelope: envelope('agentSession.send', {}), @@ -70,42 +61,30 @@ it('returns a pre-dispatch refusal without waiting on redundant uncertainty pers plan: { method: 'agentSession.send', fields: {}, - markUnknownBeforeRun: true, + settlesWithWrite: true, run: async () => { - refusing.resolve() throw refusal }, replay: () => null } - }).catch((error: unknown) => { - returned = true - return error - }) - try { - await refusing.promise - await vi.advanceTimersByTimeAsync(SETTLED_WAIT_MS) - expect(returned).toBe(true) - expect(writes).toHaveBeenCalledOnce() - expect(hostTestState().dispatch).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - } finally { - stalled.resolve() - expect(await result).toBe(refusal) - } + }).catch((error: unknown) => error) + expect(result).toBe(refusal) + expect(writes).not.toHaveBeenCalled() + expect(hostTestState().dispatch).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) }) -it.each([1, 2])( - 'preserves a proven refusal through %s failed bookkeeping writes', - async (failures) => { +it.each([ + { plan: 'answered by its write', settlesWithWrite: true as const, failures: 1 }, + { plan: 'settled after its run', settlesWithWrite: undefined, failures: 2 } +])( + 'preserves a proven refusal of a plan $plan through $failures failed bookkeeping writes', + async ({ settlesWithWrite, failures }) => { const ctx = await context() const { store, dispatch } = hostTestState() const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let writes = 0 - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async () => { - writes += 1 - if (writes > 1 && writes <= failures + 1) { - throw new Error('private unbounded disk detail') - } + const writes = vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async () => { + throw new Error('private unbounded disk detail') }) const refusal = { ok: false as const, @@ -120,12 +99,13 @@ it.each([1, 2])( plan: { method: 'agentSession.send', fields: {}, - markUnknownBeforeRun: true, + ...(settlesWithWrite ? { settlesWithWrite } : {}), run, replay: () => null } }) expect(result).toEqual(refusal) + expect(writes).toHaveBeenCalledTimes(failures) expect(run).toHaveBeenCalledOnce() expect(dispatch).not.toHaveBeenCalled() expect(JSON.stringify(warning.mock.calls)).not.toContain('private unbounded disk detail') @@ -160,7 +140,7 @@ it('accepts without touching the provider', async () => { it('refuses a superseded send at acceptance, recording and dispatching nothing', async () => { const ctx = await context() const { store } = hostTestState() - vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() + const writes = vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() const beforeRun = vi.fn(() => { throw new AgentSessionPreDispatchError('agent_session_restart_work_superseded') }) @@ -175,6 +155,7 @@ it('refuses a superseded send at acceptance, recording and dispatching nothing', plan: sendPlan({ envelope: operation, body, beforeRun }) }).catch((error: unknown) => error) expect(result).toBeInstanceOf(AgentSessionPreDispatchError) + expect(writes).not.toHaveBeenCalled() expect(ctx.journal.submissions()).toEqual([]) expect(hostTestState().dispatch).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) 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 24de6ff708b..175afeaec35 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 @@ -1,4 +1,5 @@ import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -18,20 +19,35 @@ export async function runSettledAgentSessionMutation(input: { plan: MutationPlan context: AgentSessionTurnContext }): Promise> { + const operation = { + callerKey: input.operationCallerKey, + operationId: input.envelope.clientOperationId + } const settle = ( outcome: Parameters[0]['outcome'] - ) => - input.store.recordOperationOutcome({ - callerKey: input.operationCallerKey, - operationId: input.envelope.clientOperationId, - outcome - }) + ) => input.store.recordOperationOutcome({ ...operation, outcome }) + const receipt = input.plan.settlesWithWrite + ? observedReceipt( + input.store.operationOutcomeReceipt({ + ...operation, + outcome: { status: 'succeeded', sessionId: input.envelope.sessionId } + }) + ) + : undefined + const context = receipt ? { ...input.context, operationReceipt: receipt } : input.context let outcome: TurnOutcome | undefined try { - if (input.plan.markUnknownBeforeRun) { - await settle({ status: 'unknown' }) + const ran = await input.plan.run(context) + if (receipt?.wasCommitted() && !ran.ok) { + // The row says accepted, so a refusal past it (a fold that failed after COMMIT) is a fault. + throw new Error( + `operation ${operation.operationId} was accepted, then refused: ${ran.refusal.code}` + ) } - outcome = await input.plan.run(input.context) + if (receipt?.wasCommitted()) { + return ran + } + outcome = ran await settle( outcome.ok ? (input.plan.settledOutcome?.(outcome.value) ?? { @@ -48,29 +64,42 @@ export async function runSettledAgentSessionMutation(input: { ) return outcome } catch (error) { - // The pre-run uncertainty is already durable; refusing before dispatch adds no new uncertainty. - if (input.plan.markUnknownBeforeRun && error instanceof AgentSessionPreDispatchError) { - throw error - } const { logger, sessionId } = input.context - try { - await settle({ status: 'unknown' }) - } catch { - // Bookkeeping must not replace the operation's proof of whether dispatch began. - logger.warn('recording an operation as unknown failed', { - scope: 'operation-unknown-settlement', - sessionId, - operationId: input.envelope.clientOperationId - }) + // Its success commits with its write, so a throw leaves the row pending: nothing was written. + if (!input.plan.settlesWithWrite) { + try { + await settle({ status: 'unknown' }) + } catch { + // Bookkeeping must not replace the operation's proof of whether dispatch began. + logger.warn('recording an operation as unknown failed', { + scope: 'operation-unknown-settlement', + sessionId, + operationId: operation.operationId + }) + } } if (outcome && !outcome.ok) { logger.warn('recording a refused operation failed', { scope: 'operation-refused-settlement', sessionId, - operationId: input.envelope.clientOperationId + operationId: operation.operationId }) return outcome } throw error } } + +function observedReceipt( + receipt: JournalOperationReceipt +): JournalOperationReceipt & { wasCommitted: () => boolean } { + let committed = false + return { + write: receipt.write, + committed: () => { + receipt.committed() + committed = true + }, + wasCommitted: () => committed + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index 2be4a8ea12b..144a43302b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -114,8 +114,10 @@ describe('/clear', () => { try { const cleared = command('clear') await eventually(() => expect(committing).toHaveBeenCalledOnce()) - expect(await rig.send('sent while clearing', 'queue-if-active').result).toEqual(WAIT_REFUSAL) + // Judged on arrival, answered on its turn: behind the clear. + const sent = rig.send('sent while clearing', 'queue-if-active').result release?.() + expect(await sent).toEqual(WAIT_REFUSAL) const done = await cleared const replacementId = done.ok ? done.value.replacementSessionId : undefined if (!replacementId) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index ac1fdbb0617..176fd8e11fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -23,6 +23,7 @@ import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-messag import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -185,11 +186,7 @@ export async function maybeQueueStructuredAgentSessionSend( context: { deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } }, - ctx: { - sessionId: string - journal: AgentSessionJournal - fence: number - }, + ctx: Pick, params: { envelope: { clientOperationId: string } body: AgentJournalMessageItem @@ -231,12 +228,15 @@ export async function maybeQueueStructuredAgentSessionSend( } // The insert notifies through the journal's commit listener: publication and // the drain re-derive with no call here to forget. - const row = await ctx.journal.queuedMessages.insert({ - messageId: clientMessageId, - body: params.body, - fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() - }) + const row = await ctx.journal.queuedMessages.insert( + { + messageId: clientMessageId, + body: params.body, + fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance() + }, + ctx.operationReceipt + ) return { ok: true, value: { 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 00fa33a30fb..cea36c6546a 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 @@ -13,6 +13,16 @@ export type AgentSessionReplayOutcomeDecision = | { decision: 'rerun' } | { decision: 'refuse'; refusal: AgentSessionWireRefusal } +/** A recorded id this host cannot answer from what it holds, so it neither ran it again nor + * refused it: the caller resends under the same id. */ +export function agentSessionOperationOutcomeUnknown(operationId: string): AgentSessionWireRefusal { + return refuse( + 'agent_session_operation_unknown', + { reason: 'outcomeUnknown' }, + `The outcome of operation ${operationId} is unknown; it was not run again.` + ) +} + export function resolveAgentSessionReplayOutcome(input: { operationId: string outcome: AgentSessionOperationOutcome @@ -41,14 +51,7 @@ export function resolveAgentSessionReplayOutcome(input: { if (input.rerunWhenReplayMissing) { return { decision: 'rerun' } } - return { - decision: 'refuse', - refusal: refuse( - 'agent_session_operation_unknown', - { reason: 'outcomeUnknown' }, - `The outcome of operation ${operationId} is unknown; it was not run again.` - ) - } + return { decision: 'refuse', refusal: agentSessionOperationOutcomeUnknown(operationId) } } const recorded = input.reconstruct() if (recorded) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts new file mode 100644 index 00000000000..74e4cfb4815 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts @@ -0,0 +1,241 @@ +// What a resend of a send id gets: the answer its record holds, never a refusal made before the +// host looked the id up, and never a made-up record. + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { persistRewindRecord } from './structured-rewind-recovery' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { createQueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let acquire: Mock + +beforeEach(() => { + ;({ root, store, host, dispatch, acquire } = hostTestState()) +}) + +afterEach(() => vi.restoreAllMocks()) + +function hostJournal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +async function deliveredOnce(): Promise { + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) +} + +describe('a resent send id', () => { + it('is answered from a refused row without opening the chat again', async () => { + await attach() + vi.spyOn(hostJournal(), 'appendSubmission').mockRejectedValueOnce(new Error('disk full')) + const params = sendParams('refused once') + const first = await host.send(CALLER, params) + expect(first).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + await host.close(SESSION, 'evict') + expect(host.hasSession(SESSION)).toBe(false) + + const resent = await host.send(CALLER, params) + + expect(resent).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'journalWriteFailed' } + } + }) + // The answer came from the ledger alone: the closed chat was not opened to give it. + expect(host.hasSession(SESSION)).toBe(false) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('answers unknown, never a refusal, when the chat holding its answer cannot be opened', async () => { + await attach() + const params = sendParams('recorded, then the chat would not open') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + const connection = openTestJournalHostDatabase(root).db + const prepare = connection.prepare.bind(connection) + vi.spyOn(connection, 'prepare').mockImplementation((sql: string) => { + if (sql.includes('journal_')) { + throw Object.assign(new Error('database disk image is malformed'), { + code: 'ERR_SQLITE_ERROR', + errcode: 11 + }) + } + return prepare(sql) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } + }) + // A new id meets the same chat as a first run, and is refused for what it is. + await expect(host.send(CALLER, sendParams('a new message'))).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_journal_unreadable' } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('is answered from its record while a /clear is in flight; a new id is refused', async () => { + await attach() + const params = sendParams('sent before the clear') + expect(await host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + + const clearing = host.conversationCommand(CALLER, { + command: 'clear', + envelope: envelope('agentSession.conversationCommand', { command: 'clear' }) + }) + const resent = host.send(CALLER, params) + const fresh = host.send(CALLER, sendParams('typed during the clear')) + await clearing + + await expect(resent).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + await expect(fresh).resolves.toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCommandInFlight' } } + }) + }) + + it('waits for an original still being accepted and answers with its submission', async () => { + await attach() + const journal = hostJournal() + const append = journal.appendSubmission.bind(journal) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(journal, 'appendSubmission').mockImplementationOnce(async (...args) => { + await held + return append(...args) + }) + const params = sendParams('resent while the first is mid-write') + + const original = host.send(CALLER, params) + const resent = host.send(CALLER, params) + await vi.waitFor(() => expect(journal.appendSubmission).toHaveBeenCalledTimes(1)) + release() + + const [first, second] = await Promise.all([original, resent]) + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + if (!second.ok || !('submission' in second.value)) { + throw new Error('expected the submission arm') + } + expect(second.value.submission.reason).not.toBe(DISPATCH_DOUBT_SUBMISSION_MISSING) + await deliveredOnce() + expect(journal.submissions()).toHaveLength(1) + expect( + store + .listOperationRows() + .filter((row) => row.operationId === params.envelope.clientOperationId) + ).toHaveLength(1) + }) + + it('is answered from the chat while a rewind is in doubt, starting no agent', async () => { + await attach() + const params = sendParams('sent before the rewind') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + // Only the provider can settle this rewind, so a send's first run would start it. + await persistRewindRecord(store, SESSION, store.getRecord(SESSION)!.lease.runtimeFence, { + operationId: 'rewind-op', + callerKey: CALLER.callerKey, + itemId: 'orca:rewound', + providerItemId: `codex:${THREAD}:turn-1:0`, + expectedEpoch: 'epoch-before', + phase: 'prepared', + retained: [] + }) + acquire.mockClear() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') + }) + + it('is answered from a store a newer Orca wrote, which takes no write', async () => { + await attach() + const params = sendParams('sent, then a newer Orca wrote the store') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + Object.defineProperty(openTestJournalHostDatabase(root), 'readOnly', { value: true }) + expect(store.readOnly).toBe(true) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('a send queued behind a running turn', () => { + it('commits its accepted row with its draft, so a failed settlement loses no answer', async () => { + const rig = await createQueuedMessageTestRig() + try { + await rig.workingSend() + const settle = vi + .spyOn(rig.store, 'recordOperationOutcome') + .mockRejectedValue(new Error('operation settlement failed')) + const queued = rig.send('queued behind the turn', 'queue-if-active') + + await expect(queued.result).resolves.toMatchObject({ + ok: true, + value: { queued: { messageId: queued.id, state: 'waiting' } } + }) + expect( + rig.store.listOperationRows().find((row) => row.operationId === queued.id) + ).toMatchObject({ outcome: { status: 'succeeded' } }) + expect(settle).not.toHaveBeenCalled() + } finally { + await rig.dispose() + } + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts new file mode 100644 index 00000000000..e286c8a58ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts @@ -0,0 +1,157 @@ +// A send id the ledger refuses is answered with that refusal and nothing else: no chat opened, no +// write. A /clear in flight refuses only a send's first run, judged when the send arrived. + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +const EXPIRED = { + ok: false, + refusal: { + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock + +beforeEach(() => { + ;({ root, store, host, dispatch } = hostTestState()) +}) + +afterEach(() => vi.restoreAllMocks()) + +function hostJournal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + +function sendParams(text: string, clientOperationId?: string) { + const body = hostTestMessage(text) + return { + envelope: envelope( + 'agentSession.send', + { body }, + clientOperationId ? { clientOperationId } : {} + ), + body + } +} + +/** An id minted more than a day ago, which no ledger row holds any more. */ +function expiredParams(text: string) { + return sendParams( + text, + `${NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 60_000}-${'e'.repeat(32)}` + ) +} + +function clear() { + return host.conversationCommand(CALLER, { + command: 'clear', + envelope: envelope('agentSession.conversationCommand', { command: 'clear' }) + }) +} + +describe('an expired send id', () => { + it('is answered expired while its chat is closed, not as a chat this host lacks', async () => { + await attach() + await host.close(SESSION, 'evict') + + await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED) + expect(host.hasSession(SESSION)).toBe(false) + }) + + it('is answered expired by a store a newer Orca wrote', async () => { + await attach() + const database = openTestJournalHostDatabase(root) + Object.defineProperty(database, 'readOnly', { value: true }) + expect(store.readOnly).toBe(true) + try { + await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED) + } finally { + // Teardown stops the live child, which writes. + Object.defineProperty(database, 'readOnly', { value: false }) + } + }) + + it('is answered expired when its row lapses while the chat opens for the resend', async () => { + await attach() + const params = sendParams('recorded, then it lapsed') + await host.send(CALLER, params) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + const evaluate = store.evaluateMutationOperation + // The second read, after the open, sees the clock past the row's whole retention. + vi.spyOn(store, 'evaluateMutationOperation') + .mockImplementationOnce(evaluate) + .mockImplementationOnce((args) => + evaluate({ ...args, now: args.now + 3 * AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS }) + ) + + await expect(host.send(CALLER, params)).resolves.toMatchObject(EXPIRED) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('a /clear in flight', () => { + it('answers a resend from the attempt queued ahead of it, delivering once', async () => { + await attach() + const journal = hostJournal() + const append = journal.appendSubmission.bind(journal) + const held = Promise.withResolvers() + // An earlier send holds the session's queue, so attempt 1 waits there when the clear arrives. + vi.spyOn(journal, 'appendSubmission').mockImplementationOnce(async (...args) => { + await held.promise + return append(...args) + }) + const earlier = host.send(CALLER, sendParams('holds the queue')) + const params = sendParams('queued ahead of the clear') + const id = params.envelope.clientOperationId + const attempt = host.send(CALLER, params) + await vi.waitFor(() => expect(journal.appendSubmission).toHaveBeenCalledTimes(1)) + + const clearing = clear() + const resent = host.send(CALLER, params) + held.resolve() + const [first, second] = await Promise.all([attempt, resent, earlier, clearing]) + + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: id } } + }) + expect(journal.submissions().filter((entry) => entry.clientMessageId === id)).toHaveLength(1) + expect( + dispatch.mock.calls.filter(([input]) => input.clientMessageId === id).length + ).toBeLessThanOrEqual(1) + }) + + it('answers an expired id expired', async () => { + await attach() + const clearing = clear() + const expired = host.send(CALLER, expiredParams('typed long ago')) + await clearing + + await expect(expired).resolves.toMatchObject(EXPIRED) + }) +}) 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 f1fe0a40b42..b1cbc83a48e 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 @@ -5,10 +5,10 @@ import { AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' 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 + statusNotes, + throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { @@ -66,19 +66,10 @@ it('says so in the chat when the agent cannot start for the continuation', async // 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() + const { host } = await interruptedRestart() vi.spyOn(console, 'warn').mockImplementation(() => {}) await host.restartResume.list() - const settle = store.recordOperationOutcome.bind(store) - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - 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) - }) + throwAfterContinuationAccepted() const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') 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 94746daf3ec..fe97834a189 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 @@ -13,6 +13,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' 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' @@ -160,6 +161,21 @@ export async function interruptedRestart( return { ...hostTestState(), host, store, log, closeSession, marker, clock } } +/** The continuation's submission commits, then its send throws: a send Orca may have taken. */ +export function throwAfterContinuationAccepted(): void { + const append = AgentSessionJournal.prototype.appendSubmission + vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementation(async function ( + this: AgentSessionJournal, + ...args: Parameters + ) { + const cursor = await append.apply(this, args) + if (args[0].origin === 'host') { + throw new Error('the accepted continuation could not be answered') + } + return cursor + }) +} + 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 }] : [] 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 a46551b4500..4019582e1fd 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 @@ -18,7 +18,8 @@ import { interruptedRestart, startAgent, statusNotes, - supersededRefusal + supersededRefusal, + throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' import { attach, @@ -141,38 +142,30 @@ it('replays the same logical continuation through the durable send ledger', asyn }) // A send that throws after Orca may have taken it is not proof it was not delivered. -it.each([false, true])( - 'keeps a continuation unconfirmed when its acceptance cannot be recorded (uncertainty write fails: %s)', - async (uncertaintyFails) => { - const { host, store } = await interruptedRestart() - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(await host.restartResume.list()).toHaveLength(1) - const settle = store.recordOperationOutcome.bind(store) - 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) - }) +it('keeps a continuation unconfirmed when its send throws after acceptance', async () => { + const { host, store } = await interruptedRestart() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(await host.restartResume.list()).toHaveLength(1) + throwAfterContinuationAccepted() - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + const result = await host.restartResume + .continueAfterRestart([SESSION], 'modal') + .finally(() => vi.restoreAllMocks()) - expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) - // Filed as unconfirmed, with a warning in the chat. - expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - expect(await statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - tone: 'warning' - }) - recording.mockRestore() - warning.mockRestore() - } -) + expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) + // Filed as unconfirmed, with a warning in the chat. + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + tone: 'warning' + }) + // Its acceptance committed with its submission: a resend replays it. + expect( + store + .listOperationRows() + .find((row) => row.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER) + ).toMatchObject({ outcome: { status: 'succeeded' } }) +}) // 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. 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 cf746e29b74..01f4868d380 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 @@ -274,10 +274,12 @@ describe('a send with no live owner', () => { expect(acquire).toHaveBeenCalledOnce() }) - it('restarts nothing for a send the ledger holds but the journal never saw', async () => { - const params = sendParams('claimed, then the host died') - // The row was claimed and the host went down before the journal write: on replay, admission - // reconstructs an unknown-outcome submission and never needs an owner. + /** A row admitted for this send, then the host died before the journal write, left as `outcome` + * says: `pending` by this build, `unknown` by a build that marked it before running. */ + async function admittedThenHostDied( + params: ReturnType, + outcome: 'pending' | 'unknown' + ) { await store.admitMutationOperation({ callerKey: CALLER.callerKey, envelope: params.envelope, @@ -285,11 +287,13 @@ describe('a send with no live owner', () => { now: NOW, operationIdScope: 'global' }) - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'unknown' } - }) + if (outcome === 'unknown') { + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'unknown' } + }) + } await host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -300,16 +304,31 @@ describe('a send with no live owner', () => { }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') acquire.mockClear() - - const result = await host.send(CALLER, { + return { ...params, envelope: { ...params.envelope, expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } - }) + } + } - expect(result).toMatchObject({ + it('runs a send admitted but never run for the first time, restarting the owner once', async () => { + const resent = await admittedThenHostDied(sendParams('admitted, then the host died'), 'pending') + + await expect(host.send(CALLER, resent)).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'pending' } } + }) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) + expect(acquire).toHaveBeenCalledOnce() + }) + + it("restarts nothing for an older build's unknown row the journal never saw", async () => { + const resent = await admittedThenHostDied(sendParams('marked unknown, then died'), 'unknown') + + await expect(host.send(CALLER, resent)).resolves.toMatchObject({ ok: true, replayed: true, value: { submission: { dispatchState: 'unknown', recovered: true } } 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 77413f45634..e74947054e4 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 @@ -22,7 +22,9 @@ import { AGENT_SESSION_NOT_ATTACHED, type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' +import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import { rewindRefusal } from './structured-rewind-refusal' +import { conversationCommandInFlight } from './structured-conversation-command-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -132,17 +134,29 @@ export function openWithAgent( } /** 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. */ + * the open settles every other — so a send starts the agent, whose attach recovers it. A resend + * of a recorded id needs only the conversation, its answer's source: it starts nothing, and an + * open that fails leaves that answer unknown, never refused. `clearInFlight`: a /clear was running + * when this send arrived, which refuses only its first run. */ export function sendPreparation( context: Pick, - envelope: AgentSessionMutationEnvelope -): () => Promise { - return async () => { + envelope: AgentSessionMutationEnvelope, + arrival: { clearInFlight?: boolean } = {} +): (ledger: 'admit' | 'replay') => Promise { + return async (ledger) => { + if (ledger === 'admit' && arrival.clearInFlight) { + return { ok: false, refusal: conversationCommandInFlight() } + } const opened = await openConversationForWrite( context.openConversation, envelope, context.deps.logger ) + if (ledger === 'replay') { + return opened.ok + ? opened + : { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } + } const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded') ? context.ensureAgent(envelope.sessionId) 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 f0163b22791..f382eda54e4 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 @@ -303,23 +303,20 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(1) }) - it('reconstructs an accepted send after the ledger settlement is lost', async () => { + it('answers a send whose ledger settlement fails, its row committed with the submission', async () => { await attach() - const persist = store.recordOperationOutcome.bind(store) - let failSettlement = true - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (failSettlement && input.outcome.status === 'succeeded') { - failSettlement = false - throw new Error('operation settlement failed') - } - return persist(input) - }) - const body = hostTestMessage('accepted before settlement failed') + vi.spyOn(store, 'recordOperationOutcome').mockRejectedValue( + new Error('operation settlement failed') + ) + const body = hostTestMessage('accepted while settlement fails') const params = { envelope: envelope('agentSession.send', { body }), body } + const clientMessageId = params.envelope.clientOperationId - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - // The submission was recorded before the ledger write failed, so it is still delivered. - await delivered(params.envelope.clientOperationId) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + expect( + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ outcome: { status: 'succeeded' } }) + await delivered(clientMessageId) await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, @@ -328,51 +325,54 @@ describe('send', () => { expect(dispatch).toHaveBeenCalledTimes(1) }) - it('never reruns an admission-only send after the caller changes', async () => { + it('runs a send that threw before writing for the first time when it is resent, once', async () => { await attach() - const settlement = vi - .spyOn(store, 'recordOperationOutcome') - .mockRejectedValue(new Error('operation settlement failed')) const body = hostTestMessage('first delivery after caller recovery') const params = { envelope: envelope('agentSession.send', { body }), body } + const clientMessageId = params.envelope.clientOperationId + const beforeRun = () => { + throw new Error('host fault before the write') + } - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - expect(dispatch).not.toHaveBeenCalled() - settlement.mockRestore() + await expect(host.send(CALLER, { ...params, beforeRun })).rejects.toThrow( + 'host fault before the write' + ) + expect(hostJournal().submissions()).toHaveLength(0) + // Its success would have committed with its write, so a row still pending wrote nothing. + expect( + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ outcome: { status: 'pending' } }) await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ ok: true, - replayed: true, - value: { - submission: { - dispatchState: 'unknown', - reason: DISPATCH_DOUBT_SUBMISSION_MISSING - } - } + replayed: false, + value: { submission: { clientMessageId, dispatchState: 'pending' } } }) - expect(dispatch).not.toHaveBeenCalled() + await delivered(clientMessageId) expect( - store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) - ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'pending' } }) + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'succeeded' } }) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(1) }) - it('never redelivers after admission survives without its journal submission', async () => { + it("answers an older build's unknown row a new epoch emptied as recorded, never redelivering", async () => { await attach() - const persist = store.recordOperationOutcome.bind(store) - const settlement = vi - .spyOn(store, 'recordOperationOutcome') - .mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded') { - throw new Error('operation settlement failed') - } - return persist(input) - }) const body = hostTestMessage('delivered before epoch recovery') const params = { envelope: envelope('agentSession.send', { body }), body } - - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - settlement.mockRestore() + await host.send(CALLER, params) await delivered(params.envelope.clientOperationId) + // What a build that marked a send unknown before running it leaves behind. + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'pending' } + }) + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'unknown' } + }) const journal = hostJournal() await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) expect(journal.submissions()).toHaveLength(0) @@ -394,33 +394,22 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(0) }) - it('fails closed when a legacy pending row survives without its submission', async () => { + it('answers an accepted send a new epoch dropped as recorded, never by running it again', async () => { await attach() - const body = hostTestMessage('legacy pending send after caller recovery') + const body = hostTestMessage('accepted, then the epoch was replaced') const params = { envelope: envelope('agentSession.send', { body }), body } - await host.send(CALLER, params) await delivered(params.envelope.clientOperationId) - expect(dispatch).toHaveBeenCalledTimes(1) - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'pending' } - }) + await hostJournal().rollEpoch( + 'schema_unreadable', + store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + ) - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) - - await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, value: { - submission: { - dispatchState: 'unknown', - reason: DISPATCH_DOUBT_SUBMISSION_MISSING - } + submission: { dispatchState: 'unknown', reason: DISPATCH_DOUBT_SUBMISSION_MISSING } } }) expect(dispatch).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts new file mode 100644 index 00000000000..4d33a998a59 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts @@ -0,0 +1,89 @@ +import { expect, vi } from 'vitest' +import { CLAUDE_STOP_GRACE_MS } from '../../claude/claude-request-end-wait' +import type { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import type { + fakeClaude, + FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +export function createStoppedClaudeDeadline(deps: { + host: () => Pick + adapter: () => Pick + claude: () => ReturnType + sessionId: string + stop: (turnId?: string) => ReturnType + laneDrained: () => Promise +}) { + return async ( + connection: FakeConnection, + deadline: 'interrupt' | 'request-end', + turnId?: string + ) => { + await deps.host().flushStreamedEvents(deps.sessionId) + const reachedDeadline = Promise.withResolvers< + Awaited> | undefined + >() + let deadlineSettled = false + const claude = deps.claude() + const adapter = deps.adapter() + const interrupt = claude.routes.interrupt + const requestEnd = adapter.awaitStoppedRequestEnd + const waiting = vi + .spyOn(adapter, 'awaitStoppedRequestEnd') + .mockImplementation((sessionId, at) => { + const pending = requestEnd(sessionId, at) + reachedDeadline.resolve(undefined) + return pending.then(() => { + deadlineSettled = true + }) + }) + if (deadline === 'interrupt') { + claude.routes.interrupt = (params) => { + const pending = interrupt?.(params) + if (!(pending instanceof Promise)) { + throw new Error('Expected an unanswered interrupt promise') + } + void pending.then( + () => { + deadlineSettled = true + }, + () => { + deadlineSettled = true + } + ) + reachedDeadline.resolve(undefined) + return pending + } + } + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const asked = Date.now() + const stopping = deps.stop(turnId) + void stopping.then((result) => { + if (!result.ok || !result.value.cancelled) { + reachedDeadline.resolve(result) + } + }, reachedDeadline.reject) + const early = await reachedDeadline.promise + if (early !== undefined) { + return early + } + await vi.advanceTimersByTimeAsync(CLAUDE_STOP_GRACE_MS - 1) + expect(deadlineSettled).toBe(false) + expect(connection.closed).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(deadlineSettled).toBe(true) + const result = await stopping + await deps.laneDrained() + expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) + return result + } finally { + waiting.mockRestore() + if (interrupt) { + claude.routes.interrupt = interrupt + } + vi.useRealTimers() + } + } +} 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 fa600a229dc..629d7e71790 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 @@ -24,6 +24,7 @@ import { import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter, @@ -63,6 +64,8 @@ export type AgentSessionTurnContext = { providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined /** Who a Stop's refusal row names. */ failureTextContext?: AgentSessionFailureWordsContext + /** The operation's success, committed with the row that accepts it (`MutationPlan.settlesWithWrite`). */ + operationReceipt?: JournalOperationReceipt now: () => number } @@ -142,7 +145,11 @@ export async function performSend( } } try { - await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) + await ctx.journal.appendSubmission( + { ...input, fence: ctx.fence, handoverRecorded: true }, + undefined, + ctx.operationReceipt + ) } catch (error) { // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the 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 3c7f71dbe1f..338bb494a4c 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 @@ -17,13 +17,15 @@ export class StructuredConversationCommandController { private readonly context: () => StructuredAgentSessionMutationContext, private readonly host: Pick ) {} + /** Whether a clear is in flight is read as the send arrives; it refuses only a first run, so an + * id with a recorded answer by the send's turn gets that answer, behind the clear. */ send = ( caller: StructuredAgentSessionCaller, params: Parameters[2] ): ReturnType => - this.pending.has(params.envelope.sessionId) - ? Promise.resolve({ ok: false, refusal: conversationCommandInFlight() }) - : sendStructuredAgentSessionTurn(this.context(), caller, params) + sendStructuredAgentSessionTurn(this.context(), caller, params, { + clearInFlight: this.pending.has(params.envelope.sessionId) + }) run = (caller: StructuredAgentSessionCaller, params: ConversationCommandParams) => { if (params.command === 'compact') { diff --git a/src/main/native-chat/transcript-opencode-part-blocks.ts b/src/main/native-chat/transcript-opencode-part-blocks.ts index 572851b34ae..ec8ca0cd046 100644 --- a/src/main/native-chat/transcript-opencode-part-blocks.ts +++ b/src/main/native-chat/transcript-opencode-part-blocks.ts @@ -1,6 +1,11 @@ import { fileURLToPath } from 'node:url' -import type { NativeChatBlock, NativeChatImageRefBlock } from '../../shared/native-chat-types' -import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows' +import type { + NativeChatBlock, + NativeChatImageRefBlock, + NativeChatMessage, + NativeChatTextBlock +} from '../../shared/native-chat-types' +import { agentSessionHostStatusBody } from '../../shared/agent-session-host-status-rows' import { asRecord, extractString, parseJsonObject } from '../ai-vault/session-scanner-values' // query module so each stays under the repo's file-size cap. Electron-free: // runs on the OpenCode SQLite worker thread (#8864). @@ -13,11 +18,50 @@ export type OpenCodePartRow = { export const OPENCODE_TRANSCRIPT_MAX_ROW_BYTES = 2 * 1024 * 1024 -export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBlock[] { +export function opencodeMessages( + message: Pick, + partRows: OpenCodePartRow[], + additionalBlocks: NativeChatBlock[] = [] +): NativeChatMessage[] { + const { blocks, reasoning, notices } = opencodeMessageContent(partRows) + blocks.push(...additionalBlocks) + const messages: NativeChatMessage[] = [] + if (reasoning.length > 0) { + messages.push({ + ...message, + id: blocks.length > 0 || notices.length > 0 ? `${message.id}:reasoning` : message.id, + role: 'reasoning', + blocks: reasoning, + source: 'transcript' + }) + } + if (blocks.length > 0) { + messages.push({ ...message, blocks, source: 'transcript' }) + } + if (notices.length > 0) { + messages.push({ + ...message, + id: messages.length > 0 ? `${message.id}:omission` : message.id, + role: 'system', + blocks: notices, + source: 'transcript' + }) + } + return messages +} + +function opencodeMessageContent(partRows: OpenCodePartRow[]): { + blocks: NativeChatBlock[] + reasoning: NativeChatBlock[] + notices: NativeChatTextBlock[] +} { const blocks: NativeChatBlock[] = [] + const reasoning: NativeChatBlock[] = [] + const notices: NativeChatTextBlock[] = [] for (const partRow of partRows) { if (partRow.data === null) { - blocks.push({ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }) + const { text, presentation } = agentSessionHostStatusBody('history-item-too-large') + notices.push({ type: 'text', text, presentation }) continue } const part = parseJsonObject(partRow.data) @@ -38,7 +82,7 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl case 'reasoning': { const text = extractString(part.text) if (text) { - blocks.push({ type: 'text', text }) + reasoning.push({ type: 'text', text }) } break } @@ -46,6 +90,18 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl blocks.push(...opencodeToolBlocks(part)) break } + case 'patch': { + const files = Array.isArray(part.files) + ? part.files.filter((file): file is string => typeof file === 'string') + : [] + blocks.push({ + type: 'tool-call', + name: 'patch', + state: 'completed', + input: { hash: extractString(part.hash), files } + }) + break + } case 'file': { const block = opencodeFileBlock(part) if (block) { @@ -58,7 +114,7 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl break } } - return blocks + return { blocks, reasoning, notices } } function opencodeFileBlock(part: Record): NativeChatImageRefBlock | null { diff --git a/src/main/native-chat/transcript-opencode-read-bounds.test.ts b/src/main/native-chat/transcript-opencode-read-bounds.test.ts new file mode 100644 index 00000000000..46f4ace57c1 --- /dev/null +++ b/src/main/native-chat/transcript-opencode-read-bounds.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { + readOpenCodeNativeChatTranscriptFull, + readOpenCodeNativeChatTranscriptTail +} from './transcript-opencode' + +vi.mock('../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) + +describe('OpenCode full native read bounds', () => { + it('counts reasoning, answer and image bytes cumulatively across pages', async () => { + let calls = 0 + const chunk = 'x'.repeat(1_000_000) + const messages: NativeChatMessage[] = Array.from({ length: 12 }, (_, index) => ({ + id: String(index), + role: index % 2 === 0 ? 'reasoning' : 'assistant', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'text', text: chunk }] + })) + messages.push({ + id: 'image', + role: 'user', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'image-ref', url: `data:image/png;base64,${chunk}` }] + }) + const result = await readOpenCodeNativeChatTranscriptFull('session', { + resolveDbPath: async () => 'private-db', + readPage: async () => ({ + items: messages.map((message) => ({ rowid: 100 - calls, fingerprint: '', message })), + hasMore: true, + beforeMessageRowId: 100 - ++calls + }) + }) + expect(result).toEqual({ error: 'OpenCode transcript exceeds its full read limit' }) + expect(calls).toBe(6) + }) + + it.each(['tail', 'full'] as const)( + 'keeps cancellation attached to %s discovery and reads', + async (kind) => { + const controller = new AbortController() + const resolveDbPath = vi.fn(async (_sessionId?: string, signal?: AbortSignal) => { + expect(signal).toBe(controller.signal) + return 'private-db' + }) + const readPage = vi.fn(async (_args: unknown, signal?: AbortSignal) => { + expect(signal).toBe(controller.signal) + controller.abort(new Error('Cancelled private read')) + signal?.throwIfAborted() + return null + }) + const deps = { resolveDbPath, readPage } + const result = + kind === 'tail' + ? await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'session', limit: 1 }, + deps, + controller.signal + ) + : await readOpenCodeNativeChatTranscriptFull('session', deps, controller.signal) + expect(result).toEqual({ error: 'Cancelled private read' }) + expect(readPage).toHaveBeenCalledOnce() + } + ) +}) diff --git a/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts new file mode 100644 index 00000000000..aa30a4b743a --- /dev/null +++ b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts @@ -0,0 +1,331 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows' +import Database from '../sqlite/sync-database' +import { opencodeMessages } from './transcript-opencode-part-blocks' +import { + readOpenCodeTranscriptPage, + readOpenCodeTranscriptSignal +} from './transcript-opencode-sqlite-query' +import { readOpenCodeNativeChatTranscriptFull } from './transcript-opencode' +import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe' + +vi.mock('../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) + +const fixtures: { db: Database.Database; root: string }[] = [] +afterEach(() => { + for (const { db, root } of fixtures.splice(0)) { + db.close() + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture(version: 'v1' | 'v2') { + const root = mkdtempSync(join(tmpdir(), 'orca-opencode-semantics-')) + const path = join(root, 'opencode.db') + const db = new Database(path) + fixtures.push({ db, root }) + if (version === 'v1') { + db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY); + CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT); + CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_updated INTEGER, data TEXT); + INSERT INTO session VALUES ('session');`) + } else { + db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT, session_id TEXT, type TEXT, seq INTEGER, + data TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session_v2 VALUES ('session');`) + } + let sequence = 0 + const insert = (id: string, parts: unknown[]) => { + sequence += 7 + if (version === 'v1') { + db.prepare('INSERT INTO message VALUES (?, ?, ?, ?, ?)').run( + id, + 'session', + sequence, + sequence, + JSON.stringify({ role: 'assistant' }) + ) + parts.forEach((part, index) => { + db.prepare('INSERT INTO part VALUES (?, ?, ?, ?, ?)').run( + `${id}:${index}`, + id, + 'session', + sequence, + JSON.stringify(part) + ) + }) + } else { + db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)').run( + id, + 'session', + 'assistant', + sequence, + JSON.stringify({ content: parts }), + sequence, + sequence + ) + } + } + const prefix = version === 'v1' ? '' : 'opencode:' + return { db, path, insert, prefix } +} + +const mixed = (answer = 'ANSWER_SENTINEL') => [ + { type: 'reasoning', text: 'REASONING_SENTINEL' }, + { type: 'text', text: answer } +] + +it('keeps reasoning apart from prose, image refs, tools, and applied patch records', () => { + // Contracts captured through isolated 2.0.16 and official 1.18.30 CLI ingestion. + const parts = [ + ...mixed(), + { type: 'text', text: 'injected context', synthetic: true }, + { type: 'step-finish' }, + { type: 'file', mime: 'image/png', url: 'data:image/png;base64,YQ==' }, + { type: 'patch', hash: 'snapshot-before-edit', files: ['sentinel.txt', 3] } + ].map((part) => ({ message_id: 'row', time_updated: 1, data: JSON.stringify(part) })) + const messages = opencodeMessages({ id: 'row', role: 'assistant', timestamp: 1 }, parts) + expect(messages).toEqual([ + { + id: 'row:reasoning', + role: 'reasoning', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'text', text: 'REASONING_SENTINEL' }] + }, + { + id: 'row', + role: 'assistant', + timestamp: 1, + source: 'transcript', + blocks: [ + { type: 'text', text: 'ANSWER_SENTINEL' }, + { type: 'image-ref', url: 'data:image/png;base64,YQ==' }, + { + type: 'tool-call', + name: 'patch', + state: 'completed', + input: { hash: 'snapshot-before-edit', files: ['sentinel.txt'] } + } + ] + } + ]) + expect( + opencodeMessages({ id: 'thinking', role: 'assistant', timestamp: null }, parts.slice(0, 1)) + ).toEqual([ + { + id: 'thinking', + role: 'reasoning', + timestamp: null, + source: 'transcript', + blocks: [{ type: 'text', text: 'REASONING_SENTINEL' }] + } + ]) +}) + +describe.each(['v1', 'v2'] as const)('%s semantic pagination', (version) => { + it.each([1, 2, 3])('reconstructs sparse mixed history once with a page limit of %i', (limit) => { + const { path, insert, prefix } = fixture(version) + const expected: string[] = [] + for (let index = 0; index < 12; index++) { + const id = String(index) + if (index % 3 === 1) { + insert(id, [{ type: 'step-start' }]) + } else if (index % 3 === 2) { + insert(id, mixed()) + expected.push(`${prefix}${id}:reasoning`, `${prefix}${id}`) + } else { + insert(id, [{ type: 'text', text: id }]) + expected.push(`${prefix}${id}`) + } + } + const pages: NativeChatMessage[][] = [] + let cursor: number | undefined + for (let pageIndex = 0; pageIndex < 30; pageIndex++) { + const page = readOpenCodeTranscriptPage({ + dbPath: path, + sessionId: 'session', + limit, + beforeMessageRowId: cursor + })! + expect(page.items.length).toBeLessThanOrEqual(limit + 1) + for (const item of page.items.filter((item) => item.message.role === 'reasoning')) { + expect( + page.items.some( + (other) => other.rowid === item.rowid && other.message.role === 'assistant' + ) + ).toBe(true) + } + pages.push(page.items.map((item) => item.message)) + if (!page.hasMore) { + break + } + expect(page.beforeMessageRowId).toBeLessThan(cursor ?? Number.MAX_SAFE_INTEGER) + cursor = page.beforeMessageRowId ?? undefined + } + expect( + pages + .toReversed() + .flat() + .map((message) => message.id) + ).toEqual(expected) + }) + + it('reconstructs a full read beyond its 500-message window without losing a split row', async () => { + const { path, insert, prefix } = fixture(version) + const expected: string[] = [] + for (let index = 0; index < 260; index++) { + insert(String(index), mixed()) + expected.push(`${prefix}${index}:reasoning`, `${prefix}${index}`) + } + const result = await readOpenCodeNativeChatTranscriptFull('session', { + resolveDbPath: async () => path, + readPage: async (args) => readOpenCodeTranscriptPage(args) + }) + expect('messages' in result && result.messages.map((message) => message.id)).toEqual(expected) + }) + + it('keeps both presentations on streaming mutation and new rows', async () => { + const { db, path, insert, prefix } = fixture(version) + insert('reply', mixed()) + const snapshots: NativeChatMessage[][] = [] + const replacements: NativeChatMessage[][] = [] + const appends: NativeChatMessage[][] = [] + let visible: NativeChatMessage[] = [] + const subscription = subscribeOpenCodeNativeChatTranscript( + { + agent: 'opencode', + sessionId: 'session', + initialLimit: 1, + resolvePollIntervalMs: 5, + onAppend: (messages) => { + appends.push(messages) + visible.push(...messages) + }, + onInitialSnapshot: (messages) => { + snapshots.push(messages) + visible = [...messages] + }, + onReplace: (messages) => { + replacements.push(messages) + visible = [...messages] + } + }, + undefined, + { + resolveDbPath: async () => path, + readSignal: async (dbPath, sessionId) => readOpenCodeTranscriptSignal(dbPath, sessionId), + readPage: async (args) => readOpenCodeTranscriptPage(args) + } + ) + try { + await expect.poll(() => snapshots.length).toBe(1) + expect(snapshots[0].map((message) => message.role)).toEqual(['reasoning', 'assistant']) + if (version === 'v1') { + db.prepare("UPDATE part SET data = ?, time_updated = 100 WHERE id = 'reply:1'").run( + JSON.stringify({ type: 'text', text: 'complete' }) + ) + } else { + db.prepare('UPDATE session_message SET data = ?, time_updated = 100').run( + JSON.stringify({ content: mixed('complete') }) + ) + } + await expect.poll(() => replacements.length).toBe(1) + expect(replacements[0].map((message) => message.role)).toEqual(['reasoning', 'assistant']) + expect(replacements[0][1].blocks).toEqual([{ type: 'text', text: 'complete' }]) + insert('next', mixed('next answer')) + await expect.poll(() => appends.length + replacements.length).toBe(2) + expect(visible.map((message) => message.id)).toEqual( + ['reply:reasoning', 'reply', 'next:reasoning', 'next'].map((id) => `${prefix}${id}`) + ) + expect(visible.map((message) => message.role)).toEqual([ + 'reasoning', + 'assistant', + 'reasoning', + 'assistant' + ]) + expect(visible[1].blocks).toEqual([{ type: 'text', text: 'complete' }]) + expect(visible[3].blocks).toEqual([{ type: 'text', text: 'next answer' }]) + } finally { + subscription.unsubscribe() + } + }) + + it('keeps complete pairs around an omitted oversized history row', () => { + const { path, insert, prefix } = fixture(version) + insert('before', mixed('BEFORE')) + insert('large', [ + ...mixed('MIDDLE'), + { type: 'file', mime: 'image/png', url: '😀'.repeat(2 * 1024 * 1024) } + ]) + insert('after', mixed('AFTER')) + const messages: NativeChatMessage[] = [] + const cursors = new Set() + let beforeMessageRowId: number | undefined + while (true) { + const page = readOpenCodeTranscriptPage({ + dbPath: path, + sessionId: 'session', + limit: 1, + beforeMessageRowId + }) + expect(page).not.toBeNull() + if (!page) { + throw new Error('History page unavailable') + } + messages.unshift(...page.items.map((item) => item.message)) + if (!page.hasMore) { + break + } + expect(page.beforeMessageRowId).not.toBeNull() + if (page.beforeMessageRowId === null) { + throw new Error('History cursor unavailable') + } + expect(cursors.has(page.beforeMessageRowId)).toBe(false) + cursors.add(page.beforeMessageRowId) + beforeMessageRowId = page.beforeMessageRowId + } + const middleIds = version === 'v1' ? ['large:reasoning', 'large', 'large:omission'] : ['large'] + expect(messages.map((message) => message.id)).toEqual( + ['before:reasoning', 'before', ...middleIds, 'after:reasoning', 'after'].map( + (id) => `${prefix}${id}` + ) + ) + const notices = messages.flatMap((message) => + message.blocks.filter( + (block) => + block.type === 'text' && + block.text === AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] + ) + ) + expect(notices).toHaveLength(1) + expect(notices[0]).toMatchObject({ presentation: 'history-item-too-large' }) + const noticeRow = messages.find((message) => + message.blocks.some( + (block) => block.type === 'text' && block.presentation === 'history-item-too-large' + ) + ) + expect(noticeRow?.role).toBe('system') + expect(noticeRow?.transcriptOffset).toBeTypeOf('number') + expect(Buffer.byteLength(JSON.stringify(messages))).toBeLessThan(4096) + }) + + it('enforces one page byte budget across multiple mixed rows', () => { + const { path, insert } = fixture(version) + const large = 'x'.repeat(1_000_000) + for (let index = 0; index < 20; index++) { + insert(String(index), [ + { type: 'reasoning', text: large }, + { type: 'text', text: large } + ]) + } + expect(() => + readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 40 }) + ).toThrow('read limit') + }) +}) diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts index d8261365072..6e500b7b974 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts @@ -115,7 +115,11 @@ describe('readOpenCodeTranscriptPage', () => { const page = readOpenCodeTranscriptPage(args) expect(page?.items.map((item) => item.message.id)).toEqual(['msg-2', 'msg-3']) expect(page?.items[0]?.message.blocks).toEqual([ - { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] } + { + type: 'text', + text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'], + presentation: 'history-item-too-large' + } ]) expect(page?.items[0]?.rowid).toBe(messageRowid(db, 'msg-2')) expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe( @@ -152,13 +156,17 @@ describe('readOpenCodeTranscriptPage', () => { time: 1, data: { type: 'file', mime: 'image/png', url: '😀'.repeat(600_000) } }) - expect( - readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 1 })?.items[0]?.message - .blocks - ).toEqual([ - { type: 'text', text: 'see this' }, - { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] } + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 1 }) + expect(page?.items.map((item) => item.message)).toMatchObject([ + { id: 'msg', role: 'user', blocks: [{ type: 'text', text: 'see this' }] }, + { + id: 'msg:omission', + role: 'system', + blocks: [{ type: 'text', presentation: 'history-item-too-large' }] + } ]) + expect(page?.items[0]?.rowid).toBe(page?.items[1]?.rowid) + expect(page?.items[0]?.message.transcriptOffset).toBe(page?.items[1]?.message.transcriptOffset) }) it('returns null when the session row does not exist', () => { diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index 31729111c2e..d245020b619 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -11,7 +11,7 @@ import type SyncDatabase from '../sqlite/sync-database' type BindValue = SyncDatabase.BindValue type SqliteStatement = SyncDatabase.Statement import { - opencodeMessageBlocks, + opencodeMessages, OPENCODE_TRANSCRIPT_MAX_ROW_BYTES } from './transcript-opencode-part-blocks' // Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2. @@ -166,8 +166,16 @@ export function readOpenCodeTranscriptPage(args: { break } } - const overshot = collected.length > limit - const trimmed = overshot ? collected.slice(0, limit) : collected + let retained = Math.min(limit, collected.length) + // A raw-row cursor cannot resume inside a reasoning/answer pair. + while ( + retained < collected.length && + collected[retained].rowid === collected[retained - 1].rowid + ) { + retained++ + } + const overshot = retained < collected.length + const trimmed = overshot ? collected.slice(0, retained) : collected const items = trimmed.toReversed() return { items, @@ -232,27 +240,25 @@ function mapMessageRows( const items: OpenCodeTranscriptItem[] = [] for (const row of rows) { const partList = partsByMessage.get(row.id) ?? [] - const blocks = opencodeMessageBlocks( + const record = row.data === null ? null : parseJsonObject(row.data) + const role = extractString(record?.role) + const messages = opencodeMessages( + { + id: row.id, + role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'system', + timestamp: Number.isFinite(row.time_created) ? row.time_created : null + }, row.data === null ? [{ message_id: row.id, time_updated: row.time_updated, data: null }] : partList ) - if (blocks.length === 0) { - continue + for (const message of messages.toReversed()) { + items.push({ + rowid: row.message_rowid, + fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`, + message: { ...message, transcriptOffset: row.message_rowid } + }) } - const record = row.data === null ? null : parseJsonObject(row.data) - const role = extractString(record?.role) - items.push({ - rowid: row.message_rowid, - fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`, - message: { - id: row.id, - role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'system', - blocks, - timestamp: Number.isFinite(row.time_created) ? row.time_created : null, - source: 'transcript' - } - }) } return items } diff --git a/src/main/native-chat/transcript-opencode-subscribe.test.ts b/src/main/native-chat/transcript-opencode-subscribe.test.ts index b8e0fb01e78..cce4b243cf8 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.test.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.test.ts @@ -82,9 +82,21 @@ function watchFixture( db.prepare('DELETE FROM session_message WHERE id = ?').run(String(index)) } } - for (let index = 1; index <= messageCount; index++) { - insert(index, hiddenFirst && index === 1 ? '' : undefined) + const batch = (mutate: () => void) => { + db.exec('BEGIN') + try { + mutate() + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } } + batch(() => { + for (let index = 1; index <= messageCount; index++) { + insert(index, hiddenFirst && index === 1 ? '' : undefined) + } + }) let displayed: NativeChatMessage[] = [] const onReplace = vi.fn((messages: NativeChatMessage[]) => { displayed = messages @@ -115,7 +127,17 @@ function watchFixture( } ) fixtures.push({ db, root, stop: subscription.unsubscribe }) - return { db, insert, update, remove, displayed: () => displayed, onReplace, onAppend, readPage } + return { + db, + insert, + update, + remove, + batch, + displayed: () => displayed, + onReplace, + onAppend, + readPage + } } describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (version) => { @@ -123,8 +145,10 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio const f = watchFixture(version, false, 1000) await vi.advanceTimersByTimeAsync(0) expect(f.displayed()).toHaveLength(300) - f.remove(701) - f.insert(1001) + f.batch(() => { + f.remove(701) + f.insert(1001) + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(300) expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'message 702' }]) @@ -170,9 +194,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('replaces an empty session and permits a reused provider cursor to append', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 1; index <= 300; index++) { - f.remove(index) - } + f.batch(() => { + for (let index = 1; index <= 300; index++) { + f.remove(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toEqual([]) f.insert(1, 'after revert') @@ -194,9 +220,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('continues checking previously appended history and bounds every read', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 450; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 450; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) f.update(1, 'edited after append') await vi.advanceTimersByTimeAsync(10) @@ -210,9 +238,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('holds the frontier and retries when a burst cannot bridge within the read cap', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 2800; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 2800; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(300) expect(f.onReplace).not.toHaveBeenCalled() @@ -220,10 +250,12 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio await vi.advanceTimersByTimeAsync(10) expect(f.readPage.mock.calls.length).toBeGreaterThan(reads) expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true) - for (let index = 301; index <= 2800; index++) { - f.remove(index) - } - f.insert(301, 'after discarded burst') + f.batch(() => { + for (let index = 301; index <= 2800; index++) { + f.remove(index) + } + f.insert(301, 'after discarded burst') + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(301) expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'after discarded burst' }]) @@ -233,9 +265,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) for (let start = 301; start <= 2400; start += 300) { - for (let index = start; index < start + 300; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = start; index < start + 300; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) } f.onReplace.mockClear() @@ -253,9 +287,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('keeps appending after a bridged burst fills the cap', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 2600; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 2600; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.onReplace).toHaveBeenCalledOnce() expect(f.displayed()).toHaveLength(2400) @@ -272,13 +308,15 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio async (operation) => { const f = watchFixture(version, false, 2500, 2400) await vi.advanceTimersByTimeAsync(0) - if (operation === 'delete') { - f.remove(101) - } else { - f.update(101, operation === 'hide' ? '' : 'edited first tail row') - } - f.insert(2501) - f.insert(2502) + f.batch(() => { + if (operation === 'delete') { + f.remove(101) + } else { + f.update(101, operation === 'hide' ? '' : 'edited first tail row') + } + f.insert(2501) + f.insert(2502) + }) await vi.advanceTimersByTimeAsync(10) expect(f.onReplace).toHaveBeenCalledOnce() expect(f.onAppend).not.toHaveBeenCalled() diff --git a/src/main/native-chat/transcript-opencode2-sqlite.test.ts b/src/main/native-chat/transcript-opencode2-sqlite.test.ts index 333788c82bb..7ddd653071d 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.test.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.test.ts @@ -129,9 +129,16 @@ describe('OpenCode 2 native transcript', () => { expect(page?.items[0]).toMatchObject({ rowid: 2, message: { - role: 'user', + role: 'system', timestamp: 1, - blocks: [{ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }] + transcriptOffset: 2, + blocks: [ + { + type: 'text', + text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'], + presentation: 'history-item-too-large' + } + ] } }) expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe( diff --git a/src/main/native-chat/transcript-opencode2-sqlite.ts b/src/main/native-chat/transcript-opencode2-sqlite.ts index 2ebfe950e10..1b4f01033e9 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.ts @@ -4,9 +4,10 @@ import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-pa import { columnExists, tableExists } from '../opencode-usage/schema-helpers' import { asRecord, parseJsonObject } from '../ai-vault/session-scanner-values' import { - opencodeMessageBlocks, + opencodeMessages, OPENCODE_TRANSCRIPT_MAX_ROW_BYTES } from './transcript-opencode-part-blocks' +import type { NativeChatBlock } from '../../shared/native-chat-types' import type { OpenCodeTranscriptPage, OpenCodeTranscriptSignal, @@ -55,7 +56,7 @@ export function readOpenCode2TranscriptSignal( } } -function messageItem(value: unknown): OpenCodeTranscriptItem | null { +function messageItems(value: unknown): OpenCodeTranscriptItem[] { const row = asRecord(value) if ( typeof row?.id !== 'string' || @@ -65,30 +66,30 @@ function messageItem(value: unknown): OpenCodeTranscriptItem | null { ) { throw new Error('OpenCode transcript message is invalid') } + const messageCursor = row.cursor + const updatedAt = row.time_updated if ( row.data === null || (typeof row.data === 'string' && Buffer.byteLength(row.data) > OPENCODE_TRANSCRIPT_MAX_ROW_BYTES) ) { - return { - rowid: row.cursor, - fingerprint: `${row.time_updated}:omitted`, - message: { + return opencodeMessages( + { id: `opencode:${row.id}`, - role: row.type === 'user' ? 'user' : row.type === 'assistant' ? 'assistant' : 'system', - blocks: opencodeMessageBlocks([ - { message_id: row.id, time_updated: row.time_updated, data: null } - ]), - timestamp: row.time_created, - source: 'transcript' - } - } + role: 'system', + timestamp: row.time_created + }, + [{ message_id: row.id, time_updated: row.time_updated, data: null }] + ).map((message) => ({ + rowid: messageCursor, + fingerprint: `${updatedAt}:omitted`, + message: { ...message, transcriptOffset: messageCursor } + })) } if (typeof row.data !== 'string') { throw new Error('OpenCode transcript message is invalid') } const messageId = row.id - const updatedAt = row.time_updated const record = parseJsonObject(row.data) if (!record) { throw new Error('OpenCode transcript message contains invalid JSON') @@ -140,7 +141,7 @@ function messageItem(value: unknown): OpenCodeTranscriptItem | null { } ] }) - const blocks = opencodeMessageBlocks(parts) + const blocks: NativeChatBlock[] = [] const error = asRecord(record.error) if (typeof error?.message === 'string') { blocks.push({ type: 'text', text: error.message }) @@ -149,25 +150,28 @@ function messageItem(value: unknown): OpenCodeTranscriptItem | null { blocks.push({ type: 'text', text: 'Conversation interrupted' }) } if ( - blocks.length === 0 || - (row.type !== 'user' && - row.type !== 'assistant' && - row.type !== 'system' && - row.type !== 'idle') + row.type !== 'user' && + row.type !== 'assistant' && + row.type !== 'system' && + row.type !== 'idle' ) { - return null + return [] } - return { - rowid: row.cursor, - fingerprint: `${updatedAt}:${createHash('sha256').update(row.data).digest('hex')}`, - message: { + const messages = opencodeMessages( + { id: `opencode:${row.id}`, role: row.type === 'idle' ? 'system' : row.type, - blocks, - timestamp: row.time_created, - source: 'transcript' - } - } + timestamp: row.time_created + }, + parts, + blocks + ) + const fingerprint = `${updatedAt}:${createHash('sha256').update(row.data).digest('hex')}` + return messages.toReversed().map((message) => ({ + rowid: messageCursor, + fingerprint, + message: { ...message, transcriptOffset: messageCursor } + })) } export function readOpenCode2TranscriptPage( @@ -209,9 +213,9 @@ export function readOpenCode2TranscriptPage( if (++scannedRows > MAX_SCAN_ROWS || bytes > MAX_PAGE_BYTES) { throw new Error('OpenCode transcript page exceeds its read limit') } - const item = messageItem(value) + const mapped = messageItems(value) cursor = row.cursor - if (item) { + for (const item of mapped) { pageBytes += Buffer.byteLength(JSON.stringify(item)) if (pageBytes > MAX_PAGE_BYTES) { throw new Error('OpenCode transcript page exceeds its read limit') diff --git a/src/main/opencode/hook-plugin-dispose-host.test.ts b/src/main/opencode/hook-plugin-dispose-host.test.ts index 179714d8c83..52e152753a4 100644 --- a/src/main/opencode/hook-plugin-dispose-host.test.ts +++ b/src/main/opencode/hook-plugin-dispose-host.test.ts @@ -34,6 +34,7 @@ type PluginModule = { const ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -81,6 +82,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host', } process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' process.env.ORCA_OPENCODE_AGENT = agent + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts b/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts index fb1f4010191..e9404da3821 100644 --- a/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts @@ -6,7 +6,8 @@ import { pathToFileURL } from 'node:url' import { getOpenCodePluginSource } from './status-plugin-module-source' import { writeOpenCodeTuiPlugin } from '../../shared/opencode-tui-plugin-install' import { fakeLegacyTui, type LegacyTuiEvent } from './opencode-legacy-tui-fixture' -import { readV1Plugin } from './opencode-legacy-plugin-loader-fixture' +import { getLegacyPlugins, readV1Plugin } from './opencode-legacy-plugin-loader-fixture' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' type Post = { paneKey?: string @@ -23,7 +24,8 @@ const ENV_KEYS = [ 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', - 'ORCA_AGENT_HOOK_OPENCODE_TUI' + 'ORCA_AGENT_HOOK_OPENCODE_TUI', + 'ORCA_OPENCODE_PLUGIN_API' ] as const const created = (id: string, parentID?: string): LegacyTuiEvent => ({ type: 'session.created', @@ -48,6 +50,7 @@ describe('OpenCode 1 TUI API pane reporting', () => { savedArgv = process.argv savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])) process.argv = ['opencode', 'attach', 'http://127.0.0.1:4096'] + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' process.env.ORCA_OPENCODE_AGENT = 'opencode' process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'fixture' @@ -129,6 +132,50 @@ describe('OpenCode 1 TUI API pane reporting', () => { expect(tui.listenerCount()).toBeGreaterThan(0) }) + it('loads the selected v1 factory through the release fallback once and retains the separate TUI', async () => { + process.env.ORCA_OPENCODE_PLUGIN_API = getOpenCodeCliCapabilities('1.18.30').pluginApi + process.argv = ['opencode', 'serve'] + process.env.ORCA_PANE_KEY = PANE_A + const path = join(dir, 'selected-v1-server.mjs') + writeFileSync(path, getOpenCodePluginSource()) + const plugin: unknown = await import(pathToFileURL(path).href) + if (typeof plugin !== 'object' || !plugin || !('default' in plugin)) { + throw new Error('No server plugin') + } + expect(readV1Plugin(plugin, path, 'server', 'detect')).toBeUndefined() + const factories = getLegacyPlugins(plugin) + expect(factories).toHaveLength(1) + expect(factories[0]).toBe(plugin.default) + const hooks: unknown = await factories[0]({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + if ( + !hooks || + typeof hooks !== 'object' || + !('event' in hooks) || + typeof hooks.event !== 'function' || + !('dispose' in hooks) || + typeof hooks.dispose !== 'function' + ) { + throw new Error('No legacy event hooks') + } + const dispose = hooks.dispose + cleanups.push(async () => { + await dispose() + }) + await hooks.event({ + event: { + type: 'session.status', + properties: { sessionID: 'ses_root', status: { type: 'busy' } } + } + }) + await settle('SessionBusy:ses_root') + expect(posts.at(-1)).toMatchObject({ opencodeSharedServer: 1 }) + process.argv = ['opencode', 'attach', 'http://127.0.0.1:4096'] + const tui = await start() + expect(tui.listenerCount()).toBeGreaterThan(0) + }) + it('reproduces the release loader rejection of a combined server and TUI entry', () => { expect(() => readV1Plugin({ default: { server() {}, tui() {} } }, 'orca-opencode-status-tui/tui.js', 'tui') diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index 668a879f76a..f6d07361abe 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -35,6 +35,7 @@ describe('OpenCode status plugin module contract', () => { // env. Pin every input here so the run does not depend on the developer's Orca session // (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app). const ENV_KEYS = [ + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', @@ -55,6 +56,7 @@ describe('OpenCode status plugin module contract', () => { // Why: the generated plugin self-disables when this names a different major, // so an inherited value from the developer's own Orca pane would leave // `hooks.event` undefined and fail the contract for the wrong reason. + delete process.env.ORCA_OPENCODE_PLUGIN_API delete process.env.ORCA_OPENCODE_AGENT delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' @@ -86,6 +88,13 @@ describe('OpenCode status plugin module contract', () => { return (await import(pathToFileURL(pluginPath).href)) as PluginModule } + it('selects the same factory for a v1 execution host', async () => { + process.env.ORCA_OPENCODE_PLUGIN_API = 'v1' + const module = await loadPluginModule() + expect(module.default).toBeTypeOf('function') + expect(module.default).toBe(module.OrcaOpenCodeStatusPlugin) + }) + it('exposes a default export carrying a string id and a callable server()', async () => { const module = await loadPluginModule() diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index 1ada5ea1a53..0fa86f3d70d 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -49,6 +49,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag const ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -66,6 +67,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag savedEnv[key] = process.env[key] } process.env.ORCA_OPENCODE_AGENT = agent + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts index cca4712e971..b99ee05ff97 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -31,6 +31,8 @@ type PluginModule = { default?: { setup?: (ctx: unknown) => Promise<(() => Promise) | undefined> } } +const { setTimeout: realSetTimeout, clearTimeout: realClearTimeout } = globalThis + const PANE_A = 'tabA:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const PANE_B = 'tabB:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' const SES_A = 'ses_f161fd85fffeieT0zYt80ZKorS' @@ -85,6 +87,8 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { let allPosts: Post[] let failPosts: boolean let postDelayMs: number + const cleanups = new Set<() => Promise>() + const delayedPosts = new Set<() => void>() beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-tui-adapter-')) @@ -105,7 +109,15 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { globalThis.fetch = vi.fn(async (_input, init) => { const body = JSON.parse(String(init?.body)) if (postDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, postDelayMs)) + await new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timer) + delayedPosts.delete(finish) + resolve() + } + const timer = setTimeout(finish, postDelayMs) + delayedPosts.add(finish) + }) } if (failPosts) { return new Response('{}', { status: 500 }) @@ -122,17 +134,47 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { }) }) - afterEach(() => { - globalThis.fetch = savedFetch - process.argv = savedArgv - for (const key of ENV_KEYS) { - if (savedEnv[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = savedEnv[key] + afterEach(async () => { + let deadline: ReturnType | undefined + try { + postDelayMs = 0 + for (const finish of delayedPosts) { + finish() + } + const closing = Promise.allSettled([...cleanups].map((cleanup) => cleanup())) + expect( + await Promise.race([ + closing.then((results) => results.every((result) => result.status === 'fulfilled')), + new Promise((resolve) => { + deadline = realSetTimeout(() => resolve(false), 2000) + }) + ]) + ).toBe(true) + } finally { + realClearTimeout(deadline) + try { + if (vi.isFakeTimers()) { + expect(vi.getTimerCount()).toBe(0) + } + } finally { + if (vi.isFakeTimers()) { + vi.clearAllTimers() + } + vi.useRealTimers() + cleanups.clear() + delayedPosts.clear() + globalThis.fetch = savedFetch + process.argv = savedArgv + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) } } - rmSync(tempDir, { recursive: true, force: true }) }) async function loadPlugin(dir = tempDir): Promise { @@ -140,7 +182,35 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const pluginPath = join(dir, `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs`) writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated module's default export is exercised below and fails the test if absent. - return (await import(pathToFileURL(pluginPath).href)) as PluginModule + const plugin = (await import(pathToFileURL(pluginPath).href)) as PluginModule + if (!vi.isFakeTimers()) { + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + } + const setup = plugin.default?.setup + return { + default: { + setup: async (ctx) => { + const cleanup = await setup?.(ctx) + if (!cleanup) { + return + } + const close = async (): Promise => { + try { + await cleanup() + if (vi.isFakeTimers()) { + expect(vi.getTimerCount()).toBe(0) + } + } finally { + cleanups.delete(close) + } + } + cleanups.add(close) + return close + } + } + } } const summary = (list: Post[]): string[] => @@ -156,14 +226,40 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const tui = fakeTui() const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) await script(tui) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts.slice(start)).at(-1)).toBe(`SessionIdle:${ownSession}`) }) await cleanup?.() return posts.slice(start) } - const tick = (ms = 20): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + const tick = async (ms = 20): Promise => { + await (vi.isFakeTimers() + ? vi.advanceTimersByTimeAsync(ms) + : new Promise((resolve) => setTimeout(resolve, ms))) + } + const waitFor = async ( + check: () => void, + { timeout = 1000 }: { timeout?: number } = {} + ): Promise => { + if (!vi.isFakeTimers()) { + await vi.waitFor(check, { timeout }) + return + } + const deadline = Date.now() + timeout + await tick(0) + for (;;) { + try { + check() + return + } catch (error) { + if (Date.now() >= deadline) { + throw error + } + await tick(Math.min(50, deadline - Date.now())) + } + } + } const pump = async (tui: ReturnType, events: BusEvent[]): Promise => { for (const event of events) { tui.emit(event) @@ -237,7 +333,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await pump(tui, b.start) expect(posts).toHaveLength(0) tui.navigate(SES_B) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts)).toContain(`SessionBusy:${SES_B}`) }) await pump(tui, b.finish) @@ -284,9 +380,10 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, turn(SES_A, 'A').start) tui.loseEnd(SES_A) - await vi.waitFor(() => { - expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) - }) + await tick(99) + expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) + await tick(1) + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) await cleanup?.() }) @@ -301,7 +398,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { for (const event of [...a.start, ...a.finish]) { tui.emit(event) } - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) await tick(250) @@ -319,15 +416,15 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const first = turn(SES_A, 'A') await pump(tui, [...first.start, ...first.finish]) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) tui.loseStart(SES_A) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) }) tui.loseEnd(SES_A) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) await cleanup?.() @@ -360,7 +457,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'root').start, ...childStart(SES_A)]) await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) - await vi.waitFor(() => { + await waitFor(() => { expect(posts.at(-1)?.payload).toMatchObject({ hook_event_name: 'SessionBusy', root_state: 'done', @@ -388,9 +485,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, turn(SES_A, 'root').start) await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) - await vi.waitFor(() => - expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError') - ) + await waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError')) await first?.() const before = posts.length const second = await start(tui) @@ -414,7 +509,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { { type: finishType, data: { sessionID: SES_A, reason: 'user' } } ]) const second = await start(tui) - await vi.waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined()) + await waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined()) expect(posts.at(-1)?.payload).toMatchObject({ hook_event_name: 'SessionIdle', root_state: 'done' @@ -430,13 +525,13 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, a.start) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) const beforeReload = posts.length await firstGeneration?.() expect(posts).toHaveLength(beforeReload) await pump(tui, a.finish) const secondGeneration = await start(tui) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await secondGeneration?.() expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionIdle:${SES_A}`]) }) @@ -454,7 +549,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await tick(150) expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) await pump(tui, a.finish) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await secondGeneration?.() }) @@ -468,7 +563,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await firstGeneration?.() failPosts = false const secondGeneration = await start(tui) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) await secondGeneration?.() }) @@ -483,7 +578,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { for (const event of [...a.start, ...b.start, ...b.finish, ...a.finish]) { tui.emit(event) } - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), { + await tick(119) + expect(allPosts).toEqual([]) + await tick(1) + expect(summary(allPosts)).toEqual(['SessionStart:undefined']) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), { timeout: 3000 }) await tick(300) @@ -505,11 +604,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { it('lands one start boundary in a freshly started TUI, and none on a reload after Done', async () => { const tui = fakeTui() const firstGeneration = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + await waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, [...a.start, ...a.finish]) - await vi.waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await firstGeneration?.() const secondGeneration = await start(tui) await tick(250) @@ -524,7 +623,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { it('lands the start boundary only once across reloads of an idle pane', async () => { const tui = fakeTui() const firstGeneration = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + await waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) await firstGeneration?.() const secondGeneration = await start(tui) await tick(150) @@ -536,7 +635,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const tui = fakeTui() tui.navigate(SES_B) const cleanup = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`])) + await waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`])) await cleanup?.() }) @@ -545,7 +644,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) tui.loseStart(SES_A) const cleanup = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])) await tick(150) await cleanup?.() expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`]) @@ -571,7 +670,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) expect(posts.at(-1)?.payload).toMatchObject({ root_state: 'done' }) tui.loseEnd(SES_CHILD) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await cleanup?.() expect(posts.every((post) => post.payload?.sessionID === SES_A)).toBe(true) }) @@ -603,14 +702,19 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) const a = turn(SES_A, 'A') - await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await pump(tui, [...a.start, ...childStart(SES_A)]) + await tick(60) + await pump(tui, [permission(SES_CHILD)]) + await tick(479) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await tick(1) + expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`) await pump(tui, [ { type: 'permission.replied', data: { sessionID: SES_CHILD, requestID: 'per_1' } }, { type: 'session.execution.succeeded', data: { sessionID: SES_CHILD } }, ...a.finish ]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await cleanup?.() expect(statuses(posts)).toEqual([ `SessionBusy:${SES_A}`, @@ -627,7 +731,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A spawns a background task') await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) await pump(tui, a.finish) await tick(300) expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`) @@ -639,11 +743,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) // The user browses away; the reply lands while this TUI is disconnected. tui.navigate(SES_B) await pump(tui, [{ type: 'server.connected', data: {} }]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)) await cleanup?.() }) @@ -652,7 +756,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) const release = tui.holdPermissionFetch() tui.serverPermissions.set(SES_A, [...(tui.permissions.get(SES_A) ?? [])]) await pump(tui, [ @@ -668,26 +772,22 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { // Why: an Orca restart moves the hook endpoint while the pane's level stays the same. it('re-posts the current level once the hook endpoint moves', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) - try { - const tui = fakeTui() - const cleanup = await start(tui) - tui.navigate(SES_A) - await pump(tui, turn(SES_A, 'A').start) - vi.advanceTimersByTime(200) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) - process.env.ORCA_AGENT_HOOK_PORT = '59998' - vi.advanceTimersByTime(5000) - await vi.waitFor(() => - expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) - ) - vi.advanceTimersByTime(5000) - await tick(50) - expect(statuses(posts)).toHaveLength(2) - await cleanup?.() - } finally { - vi.useRealTimers() - } + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'A').start) + await tick(200) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + process.env.ORCA_AGENT_HOOK_PORT = '59998' + await tick(4699) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await tick(1) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) + await tick(300) + await tick(5000) + await tick(50) + expect(statuses(posts)).toHaveLength(2) + await cleanup?.() }) it('does not pin Needs input on a request the data kept after its turn ended', async () => { @@ -696,9 +796,9 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, [...a.start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) await pump(tui, a.finish) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await tick(250) expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) await cleanup?.() diff --git a/src/main/opencode/hook-service-invalid-tui-config.test.ts b/src/main/opencode/hook-service-invalid-tui-config.test.ts new file mode 100644 index 00000000000..cd5b26ac01e --- /dev/null +++ b/src/main/opencode/hook-service-invalid-tui-config.test.ts @@ -0,0 +1,98 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { writeOpenCodeTuiPlugin } from '../../shared/opencode-tui-plugin-install' +import { getOpenCodePluginSource, OpenCodeHookService } from './hook-service' + +let root: string +let configDir: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-invalid-tui-config-')) + configDir = join(root, 'xdg', 'opencode') + mkdirSync(configDir, { recursive: true }) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v2') + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +it.each([ + ['tui.json', '{broken'], + ['tui.jsonc', '// keep user bytes\n{broken'], + ['tui.json', '{"plugin":"user-package"}'] +])( + 'installs the server plugin while preserving invalid %s and reporting registration failure', + (name, text) => { + const configPath = join(configDir, name) + writeFileSync(configPath, text) + new OpenCodeHookService().buildPtyEnv('pane-one') + expect(readFileSync(join(configDir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe( + getOpenCodePluginSource() + ) + expect(readFileSync(configPath, 'utf8')).toBe(text) + expect(console.warn).toHaveBeenCalledWith( + '[OpenCode] Failed to register TUI status plugin:', + expect.stringContaining('orca-opencode-status-tui'), + expect.objectContaining({ message: expect.stringContaining('Cannot register') }) + ) + } +) + +it('refreshes the retired server plugin even when its TUI config is invalid', () => { + const shared = join(root, 'profile', 'opencode-hooks', 'shared') + const serverPath = join(shared, 'plugins', 'orca-opencode-status.js') + mkdirSync(join(shared, 'plugins'), { recursive: true }) + writeFileSync(serverPath, 'stale server') + const configPath = join(shared, 'tui.jsonc') + writeFileSync(configPath, '{broken') + new OpenCodeHookService().refreshLegacySharedPlugin() + expect(readFileSync(serverPath, 'utf8')).toBe(getOpenCodePluginSource()) + expect(readFileSync(configPath, 'utf8')).toBe('{broken') + expect(console.warn).toHaveBeenCalledWith( + '[OpenCode] Failed to register TUI status plugin:', + expect.any(String), + expect.any(Error) + ) +}) + +it('installs into a source overlay without replacing the invalid user config', () => { + const source = join(root, 'source') + mkdirSync(source) + const text = '// keep\n{broken' + writeFileSync(join(source, 'tui.jsonc'), text) + const overlay = new OpenCodeHookService().buildPtyEnv('pane-one', source).OPENCODE_CONFIG_DIR + expect(overlay).not.toBe(source) + if (!overlay) { + throw new Error('No overlay') + } + expect(readFileSync(join(overlay, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe( + getOpenCodePluginSource() + ) + expect(readFileSync(join(source, 'tui.jsonc'), 'utf8')).toBe(text) + expect(readFileSync(join(overlay, 'tui.jsonc'), 'utf8')).toBe(text) + expect(console.warn).toHaveBeenCalled() +}) + +it('continues to throw file-system registration errors for the existing write retry contract', () => { + const plugins = join(configDir, 'plugins') + mkdirSync(join(configDir, 'tui.json')) + expect(() => + writeOpenCodeTuiPlugin(plugins, 'orca-opencode-status.js', getOpenCodePluginSource()) + ).toThrow() + expect(console.warn).not.toHaveBeenCalled() +}) diff --git a/src/main/opencode/hook-service-overlay-confinement.test.ts b/src/main/opencode/hook-service-overlay-confinement.test.ts new file mode 100644 index 00000000000..46a5bee5884 --- /dev/null +++ b/src/main/opencode/hook-service-overlay-confinement.test.ts @@ -0,0 +1,98 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { safeRemoveTree } from '../pty/overlay-mirror' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' + +let root: string +let source: string +let service: OpenCodeHookService + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-overlay-confinement-')) + source = join(root, 'source') + mkdirSync(join(source, 'plugins'), { recursive: true }) + writeFileSync(join(source, 'plugins', 'user.js'), 'export default {}') + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + service = new OpenCodeHookService({ + pluginFileName: 'orca-test.js', + legacyHooksDir: 'legacy-test', + overlayDir: 'overlay-test', + pluginSource: () => 'export default {}' + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +it.each(['overlay-root', 'source-overlay', 'plugins'] as const)( + 'preserves outside files when the owned %s is replaced by a directory link', + (boundary) => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + const outside = join(root, 'outside') + const externalOverlay = boundary === 'overlay-root' ? join(outside, basename(overlay)) : outside + mkdirSync(join(externalOverlay, 'plugins'), { recursive: true }) + const sentinel = join(externalOverlay, 'plugins', 'keep.js') + writeFileSync(sentinel, 'export const keep = true') + writeFileSync( + join(externalOverlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + const replaced = + boundary === 'overlay-root' + ? dirname(overlay) + : boundary === 'source-overlay' + ? overlay + : join(overlay, 'plugins') + const target = boundary === 'plugins' ? join(outside, 'plugins') : outside + if (boundary === 'plugins') { + writeFileSync( + join(overlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + } + safeRemoveTree(replaced) + symlinkSync(target, replaced, process.platform === 'win32' ? 'junction' : 'dir') + const before = readdirSync(outside, { recursive: true }).toSorted() + + const result = service.buildPtyEnv('pane-2', source) + expect(readFileSync(sentinel, 'utf8')).toBe('export const keep = true') + expect(readdirSync(outside, { recursive: true }).toSorted()).toEqual(before) + expect(result).toEqual({ OPENCODE_CONFIG_DIR: source }) + } +) + +it('still removes a stale mirrored entry from a real owned overlay', () => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + rmSync(join(source, 'plugins', 'user.js')) + expect(service.buildPtyEnv('pane-2', source)).toEqual({ OPENCODE_CONFIG_DIR: overlay }) + expect(readdirSync(join(overlay, 'plugins'))).toEqual(['orca-test.js']) +}) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index e1400f58a57..482df3671f7 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -32,6 +32,7 @@ import { import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' beforeEach(() => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v2') setAppEnvironment({ getPath: getPathMock, getAppPath: () => process.cwd(), @@ -43,6 +44,8 @@ beforeEach(() => { }) }) +afterEach(() => vi.unstubAllEnvs()) + const { isUsableId, toSafeDirName } = _internals describe('OpenCode id safety guard', () => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 41f65243ab9..7a6d137ebfa 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -2,51 +2,48 @@ import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, - statSync, - writeFileSync + statSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } from './status-plugin-module-source' +import { + readOpenCodeOverlayManifest, + clearOpenCodeOverlayManifestEntries, + writeOpenCodeOverlayManifest, + type OpenCodeOverlayManifest +} from './opencode-overlay-manifest' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' import { getOpenCodeLegacySharedConfigDir, OPENCODE2_LEGACY_HOOKS_DIR, OPENCODE_LEGACY_HOOKS_DIR } from './legacy-shared-config-dir' -import { - isInstalledOpenCodePluginCurrent, - isOverlayOpenCodePluginCurrent -} from '../../shared/opencode-installed-plugin' import { openCodeTuiPluginDirName, writeOpenCodeTuiPlugin } from '../../shared/opencode-tui-plugin-install' import { writeLegacyOpenCodePluginWithAclRetry } from './legacy-plugin-acl-retry' +import { + OPENCODE_OVERLAY_DIR, + ORCA_OPENCODE_PLUGIN_FILE, + sourceOverlayDirName, + toSafeDirName +} from './overlay-dir-names' +import { OpenCodeDirGcLifecycle } from './overlay-dir-gc-lifecycle' export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } -import { - writeCanonicalOpenCodePluginAtomically, - writeOverlayOpenCodePluginAtomically -} from '../../shared/opencode-plugin-atomic-write' - -const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' -const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' -const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' - -type OpenCodeOverlayManifest = { - topLevelEntries: string[] - pluginEntries: string[] -} +import { writeCanonicalOpenCodePluginAtomically } from '../../shared/opencode-plugin-atomic-write' +import { writeOpenCodePluginConfig } from './opencode-plugin-config-writer' type OpenCodeHookVariant = { pluginFileName: string @@ -55,6 +52,7 @@ type OpenCodeHookVariant = { pluginSource: () => string /** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */ installsTuiPlugin?: boolean + tuiOnlyDirectory?: string } // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. @@ -62,11 +60,6 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } -function toSafeDirName(id: string): string { - // Why: 32 hex chars (128 bits) makes collisions negligible and stays filesystem-portable (no base64 padding or `/`). - return createHash('sha256').update(id).digest('hex').slice(0, 32) -} - // Why: installs the plugin into OpenCode's config discovery path so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini. export class OpenCodeHookService { private readonly pluginSource: () => string @@ -74,6 +67,8 @@ export class OpenCodeHookService { private readonly legacyHooksDir: string private readonly overlayDir: string private readonly installsTuiPlugin: boolean + private readonly tuiOnlyDirectory: string | undefined + readonly configDirGc: OpenCodeDirGcLifecycle constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -93,15 +88,39 @@ export class OpenCodeHookService { }) this.pluginSource = config.pluginSource this.installsTuiPlugin = config.installsTuiPlugin === true + this.tuiOnlyDirectory = config.tuiOnlyDirectory this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir + this.configDirGc = new OpenCodeDirGcLifecycle(() => this.getOverlayRoot(), this.pluginFileName) } clearPty(_ptyId: string): void { // Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows. } + installIntoSourceOverlay( + directory: string, + sourceConfigDir: string, + owner: OpenCodeHookService + ): 'unmatched' | 'installed' | 'failed' { + if (directory !== owner.getSourceOverlayDir(sourceConfigDir)) { + return 'unmatched' + } + try { + for (const path of [owner.getOverlayRoot(), directory, join(directory, 'plugins')]) { + if (!isSafeDescendCandidate(lstatSync(path))) { + return 'failed' + } + } + this.writePluginIntoOverlay(directory) + owner.configDirGc.reference(directory) + return 'installed' + } catch { + return 'failed' + } + } + buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record { if (!isUsableId(ptyId)) { // Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited. @@ -118,14 +137,23 @@ export class OpenCodeHookService { return {} } } - if (!existsSync(existingConfigDir)) { + if (!existsSync(existingConfigDir) && !this.tuiOnlyDirectory) { return { OPENCODE_CONFIG_DIR: existingConfigDir } } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { - mkdirSync(overlayDir, { recursive: true }) - this.mirrorUserConfig(existingConfigDir, overlayDir) + // Owned directories must stay real; a replaced parent redirects both cleanup and writes. + for (const directory of [this.getOverlayRoot(), overlayDir, join(overlayDir, 'plugins')]) { + mkdirSync(directory, { recursive: true }) + if (!isSafeDescendCandidate(lstatSync(directory))) { + return { OPENCODE_CONFIG_DIR: existingConfigDir } + } + } + if (existsSync(existingConfigDir)) { + this.mirrorUserConfig(existingConfigDir, overlayDir) + } this.writePluginIntoOverlay(overlayDir) + this.configDirGc.reference(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { return { OPENCODE_CONFIG_DIR: existingConfigDir } @@ -135,6 +163,9 @@ export class OpenCodeHookService { // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. refreshLegacySharedPlugin(): void { + if (this.tuiOnlyDirectory) { + return + } const pluginsDir = join(this.getSharedConfigDir(), 'plugins') const pluginPath = join(pluginsDir, this.pluginFileName) try { @@ -188,7 +219,7 @@ export class OpenCodeHookService { } private getSourceOverlayDir(sourceConfigDir: string): string { - return join(this.getOverlayRoot(), toSafeDirName(`source:${sourceConfigDir}`)) + return join(this.getOverlayRoot(), sourceOverlayDirName(sourceConfigDir)) } private getSharedConfigDir(): string { @@ -198,48 +229,17 @@ export class OpenCodeHookService { ) } - private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { - try { - const parsed = JSON.parse( - readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') - ) as Partial - return { - topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [], - pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : [] - } - } catch { - return { topLevelEntries: [], pluginEntries: [] } - } - } - - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - writeFileSync( - join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), - `${JSON.stringify(manifest, null, 2)}\n` - ) - } - - private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - for (const entryName of manifest.topLevelEntries) { - safeRemoveTree(join(overlayDir, entryName)) - } - - const overlayPluginsDir = join(overlayDir, 'plugins') - for (const entryName of manifest.pluginEntries) { - if (entryName === this.pluginFileName) { - continue - } - safeRemoveTree(join(overlayPluginsDir, entryName)) - } - } - // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { - const previousManifest = this.readOverlayManifest(overlayDir) + const previousManifest = readOpenCodeOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. - this.clearManifestEntries(overlayDir, previousManifest) + clearOpenCodeOverlayManifestEntries(overlayDir, previousManifest, this.pluginFileName) - const nextManifest: OpenCodeOverlayManifest = { topLevelEntries: [], pluginEntries: [] } + const nextManifest: OpenCodeOverlayManifest = { + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: sourceDir + } for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name) @@ -266,6 +266,7 @@ export class OpenCodeHookService { // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. if ( pluginEntry.name === this.pluginFileName || + pluginEntry.name === this.tuiOnlyDirectory || (this.installsTuiPlugin && pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName)) ) { @@ -285,30 +286,26 @@ export class OpenCodeHookService { nextManifest.topLevelEntries.push(entry.name) } - this.writeOverlayManifest(overlayDir, nextManifest) + writeOpenCodeOverlayManifest(overlayDir, nextManifest) } - // Atomic replacement detaches mirrored links without touching user plugins. private writePluginIntoOverlay(overlayDir: string): void { - const pluginsDir = join(overlayDir, 'plugins') - mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, this.pluginFileName) - const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source, 'overlay') - if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { - writeOverlayOpenCodePluginAtomically(pluginPath, source) - } + this.writePluginToDirectory(overlayDir, 'overlay') } private writePluginToConfigDir(configDir: string): void { - const pluginsDir = join(configDir, 'plugins') - mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, this.pluginFileName) - const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source) - if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { - writeCanonicalOpenCodePluginAtomically(pluginPath, source) - } + this.writePluginToDirectory(configDir, 'canonical') + } + + private writePluginToDirectory(configDir: string, ownership: 'canonical' | 'overlay'): void { + writeOpenCodePluginConfig({ + configDir, + ownership, + pluginFileName: this.pluginFileName, + getSource: () => this.pluginSource(), + installsTuiPlugin: this.installsTuiPlugin, + tuiOnlyDirectory: this.tuiOnlyDirectory + }) } private writeTuiPlugin( diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts new file mode 100644 index 00000000000..23f6b9eea3f --- /dev/null +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +beforeEach(() => vi.mocked(runProcess).mockReset()) + +it('shares concurrent probes and isolates executable, host and environment identities', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.1.23', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/test/opencode', + env: { PATH: '/runtime', XDG_CONFIG_HOME: '/one' }, + hostIdentity: 'native' + } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].pluginApi).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, executablePath: '/other/opencode' }) + await probeOpenCodeCliVersion({ ...options, hostIdentity: 'wsl:ubuntu' }) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, XDG_CONFIG_HOME: '/two' } }) + expect(runProcess).toHaveBeenCalledTimes(4) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ args: ['--version'], timeoutMs: 5_000, maxOutputBytes: 4_096 }) + ) +}) + +it('degrades timeouts and missing executables to unknown', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: null, + signal: 'SIGTERM', + stdout: '2.0.16', + stderr: '', + timedOut: true + }) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/timeout/opencode', env: {} })).pluginApi + ).toBe('unknown') + vi.mocked(runProcess).mockRejectedValueOnce(new Error('ENOENT')) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version + ).toBeNull() +}) + +it('shares a bounded execution-host callback without probing the native machine', async () => { + const execute = vi + .fn() + .mockResolvedValue({ code: 0, timedOut: false, stdout: 'opencode v2.0.16' }) + const options = { executablePath: 'opencode', env: {}, hostIdentity: 'wsl:private', execute } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].promptMode).toBe('prefill') + expect(execute).toHaveBeenCalledTimes(1) + expect(runProcess).not.toHaveBeenCalled() +}) + +it('shares probes across pane identities while preserving launch-affecting environment keys', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '2.0.16', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/pane-cache/opencode', + cwd: '/workspace', + env: { + PATH: '/runtime', + OPENCODE_CONFIG_DIR: '/config', + ORCA_PANE_KEY: 'pane-1', + ORCA_TAB_ID: 'tab-1', + ORCA_TERMINAL_HANDLE: 'term-1', + ORCA_WORKTREE_ID: 'workspace-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_AGENT_PANE: 'pane-1', + ORCA_AGENT_LAUNCH: 'launch-1' + }, + hostIdentity: 'pane-cache-host' + } + await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion({ + ...options, + env: { + ...options.env, + ORCA_PANE_KEY: 'pane-2', + ORCA_TAB_ID: 'tab-2', + ORCA_TERMINAL_HANDLE: 'term-2', + ORCA_WORKTREE_ID: 'workspace-2', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-2', + ORCA_AGENT_PANE: 'pane-2', + ORCA_AGENT_LAUNCH: 'launch-2' + } + }) + ]) + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, PATH: '/other-runtime' } }) + await probeOpenCodeCliVersion({ + ...options, + env: { ...options.env, OPENCODE_CONFIG_DIR: '/other-config' } + }) + await probeOpenCodeCliVersion({ ...options, cwd: '/other-workspace' }) + expect(runProcess).toHaveBeenCalledTimes(4) +}) + +it('separates native and host-callback backends even with the same host identity', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.18.32', + stderr: '', + timedOut: false + }) + const options = { executablePath: '/backend/opencode', env: {}, hostIdentity: 'backend-host' } + expect((await probeOpenCodeCliVersion(options)).pluginApi).toBe('v1') + const execute = vi.fn(async () => ({ code: 0, timedOut: false, stdout: '2.0.16' })) + expect((await probeOpenCodeCliVersion({ ...options, execute })).pluginApi).toBe('v2') + expect(execute).toHaveBeenCalledOnce() + expect(runProcess).toHaveBeenCalledOnce() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts new file mode 100644 index 00000000000..06804bad2ea --- /dev/null +++ b/src/main/opencode/opencode-cli-version.ts @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' +import { runProcess } from '../../shared/child-process/run-process' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' + +export type OpenCodeCliVersionProbe = { + executablePath: string + env: NodeJS.ProcessEnv + cwd?: string + hostIdentity?: string + execute?: () => Promise<{ code: number | null; timedOut: boolean; stdout: string }> +} + +const probes = new Map }>() +const CACHE_TTL_MS = 60_000 +const MAX_CACHED_PROBES = 128 +const PANE_IDENTITY_ENV_KEYS = new Set([ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_TERMINAL_HANDLE', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV +]) + +export function probeOpenCodeCliVersion( + options: OpenCodeCliVersionProbe +): Promise { + const identity = JSON.stringify([ + options.execute ? 'host-callback' : 'native-process', + options.hostIdentity ?? process.platform, + options.executablePath, + options.cwd, + Object.entries(options.env) + .filter(([key]) => !PANE_IDENTITY_ENV_KEYS.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) + ]) + const key = createHash('sha256').update(identity).digest('hex') + const cached = probes.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + const result = runVersionProbe(options) + probes.delete(key) + probes.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, result }) + if (probes.size > MAX_CACHED_PROBES) { + const oldest = probes.keys().next().value + if (oldest !== undefined) { + probes.delete(oldest) + } + } + return result +} + +async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise { + try { + const pathKey = process.platform === 'win32' && options.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(options.executablePath) + const inheritedPath = options.env[pathKey] + const result = options.execute + ? await options.execute() + : await runProcess({ + program: options.executablePath, + args: ['--version'], + cwd: options.cwd, + env: { + ...options.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return getOpenCodeCliCapabilities(result.code === 0 && !result.timedOut ? result.stdout : null) + } catch { + return getOpenCodeCliCapabilities(null) + } +} diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts new file mode 100644 index 00000000000..12f6a8fec64 --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getOpenCodeLaunchExecutable, + probeOpenCodeLaunchCapabilities +} from './opencode-launch-capabilities' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' + +const mocks = vi.hoisted(() => ({ resolve: vi.fn(), probe: vi.fn(), wsl: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: mocks.resolve })) +vi.mock('./opencode-cli-version', () => ({ probeOpenCodeCliVersion: mocks.probe })) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) + +beforeEach(() => { + vi.clearAllMocks() + mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: true + }) +}) + +describe('OpenCode execution-host launch capability probe', () => { + it('refuses a successful version response from an unresolved WSL execution environment', async () => { + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: false + }) + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + wsl: { distro: 'Ubuntu' } + }) + expect(await mocks.probe.mock.calls[0]?.[0].execute()).toMatchObject({ code: null }) + }) + it('recognizes quoted executables and preserves explicit run commands', () => { + expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') + expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( + 'custom-launcher' + ) + expect(getOpenCodeLaunchExecutable('claude')).toBeNull() + }) + + it('uses native resolution with the execution environment and cwd', async () => { + mocks.resolve.mockResolvedValue('/bin/opencode') + const env = { PATH: '/bin', OPENCODE_CONFIG_DIR: '/private/config' } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode run task', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.resolve).toHaveBeenCalledWith('opencode', { env, cwd: '/repo' }) + expect(mocks.probe).toHaveBeenCalledWith({ + executablePath: '/bin/opencode', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.wsl).not.toHaveBeenCalled() + }) + + it('uses the relay resolver rather than client PATH resolution', async () => { + const resolveExecutable = vi.fn().mockResolvedValue('/host/opencode') + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + hostIdentity: 'relay:linux', + resolveExecutable + }) + expect(resolveExecutable).toHaveBeenCalledWith('opencode') + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ executablePath: '/host/opencode', hostIdentity: 'relay:linux' }) + ) + }) + + it('returns unknown when the host cannot resolve the binary', async () => { + mocks.resolve.mockResolvedValue(null) + expect(await probeOpenCodeLaunchCapabilities({ command: 'opencode', env: {} })).toEqual( + getOpenCodeCliCapabilities(null) + ) + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('bounds WSL probes and matches guest cwd plus explicitly imported config roots', async () => { + const env = { + HOME: '/native', + PATH: '/native/bin', + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: 'OPENCODE_CONFIG_DIR:XDG_DATA_HOME' + } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode --standalone', + env, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo', + wsl: { distro: 'Debian' }, + hostIdentity: 'host-a' + }) + expect(mocks.resolve).not.toHaveBeenCalled() + const options = mocks.probe.mock.calls[0]?.[0] + const guestEnv = { + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: env.WSLENV + } + expect(options).toEqual( + expect.objectContaining({ + executablePath: 'opencode', + hostIdentity: 'host-a:wsl:Ubuntu', + cwd: '/home/user/repo', + env: guestEnv + }) + ) + await options.execute() + expect(mocks.wsl).toHaveBeenCalledWith({ + distro: 'Ubuntu', + loginPath: 'preferred', + cwd: '/home/user/repo', + program: 'opencode', + args: ['--version'], + env: guestEnv, + timeoutMs: 5000, + maxOutputBytes: 4096 + }) + }) + + it('does not import native configuration the actual WSL pane would not receive', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { OPENCODE_CONFIG_DIR: 'C:\\native' }, + cwd: 'D:\\repo', + wsl: { distro: 'Ubuntu' } + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/d/repo', env: { WSLENV: '' } }) + ) + }) + + it('keeps WSL path translation flags and skips Windows-only values', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { + WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', + XDG_DATA_HOME: 'D:\\data', + WINDOWS_ONLY: 'private', + HOME: 'C:\\native' + }, + wsl: {} + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ + env: { WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', XDG_DATA_HOME: 'D:\\data' } + }) + ) + }) +}) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts new file mode 100644 index 00000000000..ee056ee9d4b --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -0,0 +1,99 @@ +import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' +import { + getFirstCommandToken, + getCommandTokenPathBasename +} from '../../shared/command-token-scanner' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' +import type { TuiAgent } from '../../shared/tui-agent' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { runWslProcess } from '../wsl/wsl-runner' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +export function getOpenCodeLaunchExecutable( + command: string | undefined, + agent?: TuiAgent +): string | null { + const executable = getFirstCommandToken(command ?? '') + const name = getCommandTokenPathBasename(executable) + .toLowerCase() + .replace(/\.(?:exe|cmd|sh)$/, '') + return agent === 'opencode' || + agent === 'opencode2' || + (!agent && (name === 'opencode' || name === 'opencode2')) + ? executable || null + : null +} + +export async function probeOpenCodeLaunchCapabilities(options: { + command: string | undefined + agent?: TuiAgent + env: NodeJS.ProcessEnv + cwd?: string + wsl?: { distro?: string } + hostIdentity?: string + resolveExecutable?: (executable: string) => Promise +}): Promise { + const executable = getOpenCodeLaunchExecutable(options.command, options.agent) + if (!executable) { + return null + } + if (options.wsl) { + // An empty carrier prevents the runner from restoring deleted ambient imports. + const guestEnv: Record = { WSLENV: '' } + const wslEnv = options.env.WSLENV?.split(':') + .filter((token) => options.env[token.split('/')[0]] !== undefined) + .join(':') + if (wslEnv) { + guestEnv.WSLENV = wslEnv + for (const token of wslEnv.split(':')) { + const [key, flags = ''] = token.split('/') + if (!key || flags.includes('w') || ['PATH', 'HOME', 'TMP', 'TEMP'].includes(key)) { + continue + } + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } + } + } + const wslPath = options.cwd ? parseWslUncPath(options.cwd) : null + const cwd = options.cwd ? (wslPath?.linuxPath ?? toLinuxPath(options.cwd)) : undefined + if (cwd !== undefined && !cwd.startsWith('/')) { + return getOpenCodeCliCapabilities(null) + } + const distro = wslPath?.distro ?? options.wsl.distro + return probeOpenCodeCliVersion({ + executablePath: executable, + env: guestEnv, + cwd, + hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, + execute: async () => { + const result = await runWslProcess({ + distro, + loginPath: 'preferred', + cwd, + program: executable, + args: ['--version'], + env: guestEnv, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return result.environmentResolved ? result : { ...result, code: null } + } + }) + } + const executablePath = options.resolveExecutable + ? await options.resolveExecutable(executable) + : await resolveCommandOnLocalPath(executable, { env: options.env, cwd: options.cwd }) + return executablePath + ? probeOpenCodeCliVersion({ + executablePath, + env: options.env, + cwd: options.cwd, + hostIdentity: options.hostIdentity + }) + : getOpenCodeCliCapabilities(null) +} diff --git a/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts b/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts index 15c2f238a99..3a5a341a08d 100644 --- a/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts +++ b/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts @@ -44,3 +44,39 @@ export function readV1Plugin( return value } + +// OpenCode v1.18.30 (5cd8e68): packages/opencode/src/plugin/index.ts. +type ServerPlugin = (...args: unknown[]) => unknown +function isServerPlugin(value: unknown): value is ServerPlugin { + return typeof value === 'function' +} + +function getServerPlugin(value: unknown) { + if (isServerPlugin(value)) { + return value + } + if (!value || typeof value !== 'object' || !('server' in value)) { + return + } + if (!isServerPlugin(value.server)) { + return + } + return value.server +} + +export function getLegacyPlugins(mod: Record) { + const seen = new Set() + const result: ServerPlugin[] = [] + for (const entry of Object.values(mod)) { + if (seen.has(entry)) { + continue + } + seen.add(entry) + const plugin = getServerPlugin(entry) + if (!plugin) { + throw new TypeError('Plugin export is not a function') + } + result.push(plugin) + } + return result +} diff --git a/src/main/opencode/opencode-overlay-manifest.test.ts b/src/main/opencode/opencode-overlay-manifest.test.ts new file mode 100644 index 00000000000..65c2c765bbb --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE +} from './opencode-overlay-manifest' + +it('accepts only string manifest entries and tolerates invalid persisted shapes', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-manifest-')) + try { + const path = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const text of ['null', '1', '{']) { + writeFileSync(path, text) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + path, + JSON.stringify({ + topLevelEntries: ['valid', 1, null], + pluginEntries: [{ bad: true }, 'plugin.js'] + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['valid'], + pluginEntries: ['plugin.js'] + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('keeps source ownership optional for older manifests and rejects non-string metadata', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-source-manifest-')) + try { + const file = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const source of [undefined, null, 42, {}]) { + writeFileSync( + file, + JSON.stringify({ topLevelEntries: [], pluginEntries: [], sourceConfigDir: source }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + file, + JSON.stringify({ + topLevelEntries: ['opencode.json'], + pluginEntries: [], + sourceConfigDir: '/owned/source' + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['opencode.json'], + pluginEntries: [], + sourceConfigDir: '/owned/source' + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/opencode/opencode-overlay-manifest.ts b/src/main/opencode/opencode-overlay-manifest.ts new file mode 100644 index 00000000000..a256c14f8ca --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.ts @@ -0,0 +1,64 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { safeRemoveOverlay } from '../pty/overlay-mirror' + +export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' +export type OpenCodeOverlayManifest = { + topLevelEntries: string[] + pluginEntries: string[] + sourceConfigDir?: string +} + +export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { + const empty = { topLevelEntries: [], pluginEntries: [] } + try { + const parsed: unknown = JSON.parse( + readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') + ) + if (!parsed || typeof parsed !== 'object') { + return empty + } + return { + ...('sourceConfigDir' in parsed && typeof parsed.sourceConfigDir === 'string' + ? { sourceConfigDir: parsed.sourceConfigDir } + : {}), + topLevelEntries: + 'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries) + ? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string') + : [], + pluginEntries: + 'pluginEntries' in parsed && Array.isArray(parsed.pluginEntries) + ? parsed.pluginEntries.filter((entry): entry is string => typeof entry === 'string') + : [] + } + } catch { + return empty + } +} + +export function writeOpenCodeOverlayManifest( + overlayDir: string, + manifest: OpenCodeOverlayManifest +): void { + writeFileSync( + join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), + `${JSON.stringify(manifest, null, 2)}\n` + ) +} + +export function clearOpenCodeOverlayManifestEntries( + overlayDir: string, + manifest: OpenCodeOverlayManifest, + pluginFileName: string +): void { + for (const entryName of manifest.topLevelEntries) { + safeRemoveOverlay(join(overlayDir, entryName), overlayDir) + } + const overlayPluginsDir = join(overlayDir, 'plugins') + for (const entryName of manifest.pluginEntries) { + if (entryName === pluginFileName) { + continue + } + safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) + } +} diff --git a/src/main/opencode/opencode-plugin-config-writer.ts b/src/main/opencode/opencode-plugin-config-writer.ts new file mode 100644 index 00000000000..98cfe1e7088 --- /dev/null +++ b/src/main/opencode/opencode-plugin-config-writer.ts @@ -0,0 +1,45 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from '../../shared/opencode-installed-plugin' +import { + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from '../../shared/opencode-plugin-atomic-write' +import { + writeOpenCodeTuiPlugin, + writeOpenCodeTuiPluginDirectory +} from '../../shared/opencode-tui-plugin-install' + +export function writeOpenCodePluginConfig(options: { + configDir: string + pluginFileName: string + getSource: () => string + installsTuiPlugin: boolean + tuiOnlyDirectory: string | undefined + ownership: 'canonical' | 'overlay' +}): void { + const pluginsDir = join(options.configDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const pluginPath = join(pluginsDir, options.pluginFileName) + const source = options.getSource() + if (options.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, options.tuiOnlyDirectory, source, options.ownership) + return + } + if (options.installsTuiPlugin) { + writeOpenCodeTuiPlugin(pluginsDir, options.pluginFileName, source, options.ownership) + } + const overlay = options.ownership === 'overlay' + const current = overlay + ? isOverlayOpenCodePluginCurrent(pluginPath, source) + : isInstalledOpenCodePluginCurrent(pluginPath, source) + if (!current) { + const write = overlay + ? writeOverlayOpenCodePluginAtomically + : writeCanonicalOpenCodePluginAtomically + write(pluginPath, source) + } +} diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts new file mode 100644 index 00000000000..854882d287f --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -0,0 +1,337 @@ +import { createHash } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' +import { + buildLocalPtySpawnEnvironment, + enforceLocalPtySpawnEnvironmentOverrides +} from '../providers/local-pty-spawn-environment' +import type { LocalPtyLaunchPlan } from '../providers/local-pty-launch-plan' + +const plan: LocalPtyLaunchPlan = { + startupAgentRecognition: null, + defaultCwd: '', + cwd: '', + wslInfo: null, + worktreeWslContext: undefined, + preferredWslContext: undefined, + launchWslContext: undefined, + shellPath: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + shellArgs: [], + effectiveCwd: '', + validationCwd: '', + startupCommandDeliveredInShellArgs: false, + windowsFallbackAttempts: [], + shellReadyLaunch: null, + getFallbackShellReadyConfig: undefined, + primaryPreLaunchEnv: {}, + isWslShell: false, + launchWslDistro: null +} + +const hookServer = vi.hoisted(() => { + const server: { endpointFilePath: string | null } = { endpointFilePath: '/private/endpoint.env' } + return server +}) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: hookServer })) +const reserve = vi.hoisted(() => vi.fn(() => true)) +vi.mock('./opencode-startup-prompt-owner', () => ({ reserveOpenCodeStartupPrompt: reserve })) + +async function prepare(options: Parameters[0]) { + return (await prepareOpenCodePtyLaunch(options)).env +} + +const probe = vi.hoisted(() => vi.fn()) +const install = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-startup-prompt-installer', () => ({ + installOpenCodeStartupPromptForLaunch: install +})) +vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) + +beforeEach(() => { + probe.mockReset() + reserve.mockReset().mockReturnValue(true) + install.mockReset() + hookServer.endpointFilePath = '/private/endpoint.env' +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('execution-host OpenCode launch preparation', () => { + it('retains fresh owned source provenance through the final provider deletion pass', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + install.mockImplementation((env) => { + env.OPENCODE_CONFIG_DIR = '/private/owned-overlay' + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/private/real-source' + return true + }) + const envToDelete = ['OPENCODE_CONFIG_DIR', 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'] + const env = await prepare({ + command: 'opencode --prompt task', + isFreshLaunch: true, + envToDelete, + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: createHash('sha256').update('task').digest('hex'), + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'task', + ORCA_OPENCODE_STARTUP_PROMPT_SHELL: 'posix' + } + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'source-proof', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.OPENCODE_CONFIG_DIR).toBe('/private/owned-overlay') + expect(finalEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/private/real-source') + }) + it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const env = { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + const options = { env, envToDelete: [], isFreshLaunch: true } + expect( + ( + await prepareOpenCodePtyLaunch({ + ...options, + command: "opencode --standalone --prompt 'task'" + }) + ).command + ).toBe('opencode --standalone') + expect( + (await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" })) + .command + ).toBe("opencode run --prompt 'task'") + expect( + (await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" })) + .command + ).toBe("opencode --prompt 'task'") + }) + it.each(['inherited', 'explicit', 'deleted'] as const)( + 'passes the %s config environment used by the execution-host version probe to the prompt installer', + async (selection) => { + vi.stubEnv('XDG_CONFIG_HOME', '/ambient/config') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + await prepareOpenCodePtyLaunch({ + command: 'opencode --prompt task', + envToDelete: selection === 'deleted' ? ['XDG_CONFIG_HOME'] : [], + isFreshLaunch: true, + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix', + ...(selection === 'explicit' ? { XDG_CONFIG_HOME: '/selected/config' } : {}) + } + }) + expect(install).toHaveBeenCalledTimes(1) + const resolved = install.mock.calls[0][2] + expect(resolved).toEqual(probe.mock.calls[0][0].env) + expect(resolved.XDG_CONFIG_HOME).toBe( + selection === 'deleted' + ? undefined + : selection === 'explicit' + ? '/selected/config' + : '/ambient/config' + ) + } + ) + + it.each(['endpoint', 'installer', 'capacity', 'identity'])( + 'keeps the editable brief when automatic preparation lacks %s', + async (failure) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + if (failure === 'endpoint') { + hookServer.endpointFilePath = null + } + if (failure === 'installer') { + install.mockImplementation((env) => { + delete env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE + }) + } + if (failure === 'capacity') { + reserve.mockReturnValue(false) + } + const original = "opencode --standalone --prompt 'task'" + const result = await prepareOpenCodePtyLaunch({ + command: original, + envToDelete: [], + isFreshLaunch: true, + env: { + ...(failure === 'identity' ? {} : { ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch' }), + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + }) + expect(result.command).toBe(original) + expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE') + } + ) + it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( + 'gates native intent against the executing %s capability', + async (version) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) + const envToDelete: string[] = [] + const fingerprint = createHash('sha256').update('task').digest('hex') + const env = await prepare({ + command: 'opencode --prompt task', + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }, + envToDelete, + isFreshLaunch: true + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe( + version === '2.0.16' ? fingerprint : undefined + ) + expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16') + } + ) + + it('refuses remote automatic intent without execution-owned driving input', async () => { + const fingerprint = 'b'.repeat(64) + const envToDelete: string[] = [] + const env = await prepare({ + command: 'opencode --prompt task', + connectionId: 'remote', + isFreshLaunch: true, + env: { [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint }, + envToDelete + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBeUndefined() + expect(envToDelete).toContain(OPENCODE_STARTUP_PROMPT_SHA256_ENV) + expect(probe).not.toHaveBeenCalled() + }) + it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') + vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) + const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] + const env = await prepare({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const probeEnv = probe.mock.calls[0]?.[0].env + expect(probeEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(probeEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(probeEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(finalEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(finalEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + }) + + it('retains a verified selection through the final provider deletion pass', async () => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] + const env = await prepare({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + finalEnv.KEEP_DELETED = 'dummy' + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.ORCA_OPENCODE_PLUGIN_API).toBe('v2') + expect(finalEnv).not.toHaveProperty('KEEP_DELETED') + }) + + it.each(['1.1.23', '2.0.16'])( + 'selects the probed %s plugin for the execution host', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + probe.mockResolvedValue(capabilities) + const env = { + KEEP: '1', + ORCA_OPENCODE_PLUGIN_API: 'stale' + } + const result = await prepare({ + command: 'opencode --prompt test', + agent: 'opencode', + env, + envToDelete: [], + cwd: '/repo', + isFreshLaunch: true + }) + expect(result).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }) + expect(env).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' }) + expect(probe).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'opencode --prompt test', + cwd: '/repo', + env: expect.objectContaining({ KEEP: '1' }) + }) + ) + } + ) + + it('creates a launch environment for a known binary without caller env', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + expect( + await prepare({ + command: 'opencode', + env: undefined, + envToDelete: [], + isFreshLaunch: true + }) + ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) + }) + + it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) + const env = { KEEP: '1' } + const result = await prepare({ + command: 'opencode', + agent: 'opencode', + env, + envToDelete: [], + isFreshLaunch: true, + wsl: { distro: 'Ubuntu' } + }) + expect(result).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'ORCA_OPENCODE_PLUGIN_API' + }) + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ wsl: { distro: 'Ubuntu' } })) + }) + + it.each([{ connectionId: 'remote', isFreshLaunch: true }, { isFreshLaunch: false }])( + 'never probes the client for an attach or SSH launch', + async (route) => { + const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } + expect(await prepare({ command: 'opencode', env, envToDelete: [], ...route })).toEqual({}) + expect(probe).not.toHaveBeenCalled() + expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) + } + ) +}) diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts new file mode 100644 index 00000000000..bf7d8bced1f --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.ts @@ -0,0 +1,139 @@ +import { addWslEnvKeys } from '../../shared/wsl-env' +import type { TuiAgent } from '../../shared/tui-agent' +import { randomUUID, createHash } from 'node:crypto' +import { agentHookServer } from '../agent-hooks/server' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' + +const intentKeys = [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +] + +import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { reserveOpenCodeStartupPrompt } from './opencode-startup-prompt-owner' +import { installOpenCodeStartupPromptForLaunch } from './opencode-startup-prompt-installer' + +export async function prepareOpenCodePtyLaunch(options: { + command: string | undefined + agent?: TuiAgent + env: Record | undefined + envToDelete: string[] + cwd?: string + connectionId?: string | null + isFreshLaunch: boolean + wsl?: { distro?: string } +}): Promise<{ env: Record | undefined; command: string | undefined }> { + let command = options.command + const env = options.env ? { ...options.env } : undefined + const requestedPrompt = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const body = env?.[OPENCODE_STARTUP_PROMPT_BODY_ENV] + const shell = env?.[OPENCODE_STARTUP_PROMPT_SHELL_ENV] + if (env) { + delete env.ORCA_OPENCODE_PLUGIN_API + for (const key of intentKeys) { + delete env[key] + } + } + // Providers merge their own ambient environment after this preparation. + if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { + options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') + } + for (const key of intentKeys) { + if (!options.envToDelete.includes(key)) { + options.envToDelete.push(key) + } + } + if (options.connectionId || !options.isFreshLaunch) { + return { env, command } + } + const probeEnv: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...env })) { + if (value !== undefined) { + probeEnv[key] = value + } + } + deleteRequestedEnvKeys(probeEnv, options.envToDelete) + const capabilities = await probeOpenCodeLaunchCapabilities({ + ...options, + env: probeEnv + }) + if (!capabilities || capabilities.pluginApi === 'unknown') { + return { env, command } + } + const launchEnv: Record = { + ...env, + ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi + } + options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) + if ( + capabilities.promptMode === 'prefill' && + !options.wsl && + launchEnv.ORCA_AGENT_LAUNCH_TOKEN && + requestedPrompt && + body && + command && + (shell === 'posix' || shell === 'powershell' || shell === 'cmd') && + createHash('sha256').update(body).digest('hex') === requestedPrompt + ) { + const parsed = tokenizeStartupCommand(command, shell) + const last = parsed.ok ? parsed.tokens.length - 1 : -1 + if ( + parsed.ok && + !isOpenCodeRunCommand(parsed.tokens, shell) && + parsed.tokens.filter((token) => token === '--prompt').length === 1 && + parsed.tokens[last - 1] === '--prompt' && + parsed.tokens[last] === body && + !parsed.spans[last].divergesFromShell && + !parsed.spans[last - 1].divergesFromShell + ) { + const endpoint = agentHookServer.endpointFilePath + if (endpoint) { + launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt + launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body + launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() + launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + if (installOpenCodeStartupPromptForLaunch(launchEnv, false, probeEnv)) { + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ]) { + const deletion = options.envToDelete.indexOf(key) + if (launchEnv[key] && deletion !== -1) { + options.envToDelete.splice(deletion, 1) + } + } + } + const nonce = launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce && reserveOpenCodeStartupPrompt(nonce, requestedPrompt)) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + } else { + for (const key of intentKeys) { + delete launchEnv[key] + } + } + for (const key of intentKeys) { + if (launchEnv[key]) { + options.envToDelete.splice(options.envToDelete.indexOf(key), 1) + } + } + } + } + } + if (options.wsl) { + addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + return { env: launchEnv, command } +} diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts new file mode 100644 index 00000000000..95f2f51fcbd --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +describe('execution-owned startup prompt claims', () => { + it('consumes each nonce once and checks owner facts at claim time', () => { + const claims = new OpenCodeStartupPromptClaims() + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('first', 'hash', () => facts) + facts.firstUserInputAt = 1 + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + facts.firstUserInputAt = null + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + claims.register('second', 'hash', () => facts) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(true) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(false) + claims.register('missing', 'hash', () => facts) + facts = null + expect(claims.claim({ nonce: 'missing', digest: 'hash' })).toBe(false) + }) + + it('refuses reattachment, mismatched hashes, expired claims and malformed bodies', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('reattach', 'hash', () => ({ freshSpawn: false, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'reattach', digest: 'hash' })).toBe(false) + claims.register('mismatch', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'mismatch', digest: 'other' })).toBe(false) + expect(claims.claim({ nonce: 'mismatch', digest: 'hash' })).toBe(false) + claims.register('expired', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + for (const body of [null, 1, {}, { nonce: 1 }, { nonce: 'absent' }]) { + expect(claims.claim(body)).toBe(false) + } + }) + + it('keeps pending admission bounded and never recreates canceled or consumed claims', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('waiting', 'hash', () => 'pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(true) + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe(true) + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('canceled', 'hash', () => 'pending') + claims.cancel('canceled') + expect(claims.admit('canceled', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('expired', 'hash', () => 'pending') + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + expect(claims.admit('expired', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.clear() + }) + + it('bounds pending claims and reclaims expired capacity without timers', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + for (let index = 0; index < 129; index++) { + claims.register(String(index), 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + } + expect(claims.claim({ nonce: '128', digest: 'hash' })).toBe(false) + now = 20000 + claims.register('new', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'new', digest: 'hash' })).toBe(true) + }) + it('starts a fresh bounded claim window after delayed spawn admission', () => { + vi.useFakeTimers() + try { + const claims = new OpenCodeStartupPromptClaims() + claims.register('slow-spawn', 'hash', () => 'pending') + vi.advanceTimersByTime(18000) + const cleanup = vi.fn() + expect( + claims.admit('slow-spawn', () => ({ freshSpawn: true, firstUserInputAt: null }), cleanup) + ).toBe(true) + vi.advanceTimersByTime(8000) + expect(claims.claim({ nonce: 'slow-spawn', digest: 'hash' })).toBe(true) + expect(cleanup).toHaveBeenCalledTimes(1) + claims.clear() + } finally { + vi.useRealTimers() + } + }) + + it('replays only the same operation while execution facts remain authorized', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + const cleanup = vi.fn() + claims.register('retry', 'hash', () => facts, cleanup) + const body = { nonce: 'retry', digest: 'hash', requestId: 'stable-operation' } + expect(claims.claim(body)).toBe(true) + expect(claims.claim(body)).toBe(true) + expect(claims.claim({ ...body, requestId: 'another-operation' })).toBe(false) + expect(claims.claim({ nonce: 'retry', digest: 'hash' })).toBe(false) + expect(cleanup).not.toHaveBeenCalled() + expect(claims.claim(body)).toBe(true) + facts.firstUserInputAt = 1 + expect(claims.claim(body)).toBe(false) + expect(cleanup).toHaveBeenCalledTimes(1) + facts = { freshSpawn: true, firstUserInputAt: null } + expect(claims.claim(body)).toBe(false) + claims.register('expires', 'hash', () => facts, cleanup) + expect(claims.claim({ ...body, nonce: 'expires' })).toBe(true) + now = 20000 + expect(claims.claim({ ...body, nonce: 'expires' })).toBe(false) + expect(cleanup).toHaveBeenCalledTimes(2) + }) + + it('does not renew admission, retain unbounded IDs or replay retired owners', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('bound', 'hash', () => 'pending') + now = 18000 + expect(claims.admit('bound', () => facts)).toBe(true) + now = 37000 + expect(claims.admit('bound', () => facts)).toBe(false) + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'x'.repeat(129) })).toBe(false) + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(true) + facts = null + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(false) + claims.register('clear', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(true) + claims.clear() + expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(false) + }) + + it.each([null, 1, '', 'with spaces', 'x'.repeat(129)])( + 'rejects malformed operation ID %j without consuming valid authorization', + (requestId) => { + const claims = new OpenCodeStartupPromptClaims() + claims.register('valid', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId })).toBe(false) + expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId: 'valid-operation' })).toBe( + true + ) + claims.clear() + } + ) + + it('expires the renewed window without permitting repeated admission to extend it', () => { + vi.useFakeTimers() + try { + const claims = new OpenCodeStartupPromptClaims() + claims.register('bounded', 'hash', () => 'pending') + vi.advanceTimersByTime(18000) + const owner = () => ({ freshSpawn: true, firstUserInputAt: null }) + const cleanup = vi.fn() + expect(claims.admit('bounded', owner, cleanup)).toBe(true) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true) + vi.advanceTimersByTime(19999) + expect(claims.admit('bounded', owner)).toBe(false) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true) + vi.advanceTimersByTime(1) + expect(cleanup).toHaveBeenCalledTimes(1) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(false) + claims.clear() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts new file mode 100644 index 00000000000..3a8dca6053f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -0,0 +1,120 @@ +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' + +type PromptClaim = { + digest: string + expiresAt: number + admitted: boolean + grantedRequestId?: string + readOwner: () => TerminalRunFacts | 'pending' | null + cleanup: () => void + expiry: ReturnType +} + +/** Authorizes one startup operation against current execution-owner facts. */ +export class OpenCodeStartupPromptClaims { + private readonly pending = new Map() + + constructor(private readonly now: () => number = Date.now) {} + + register( + nonce: string, + digest: string, + readOwner: PromptClaim['readOwner'], + cleanup = () => {} + ): boolean { + const now = this.now() + for (const [key, claim] of this.pending) { + if (claim.expiresAt <= now) { + this.cancel(key) + } + } + if (this.pending.size >= 128 || this.pending.has(nonce)) { + return false + } + const expiry = setTimeout(() => this.cancel(nonce), 20000) + expiry.unref?.() + this.pending.set(nonce, { + digest, + readOwner, + expiresAt: now + 20000, + admitted: false, + cleanup, + expiry + }) + return true + } + + admit(nonce: string, readOwner: PromptClaim['readOwner'], cleanup = () => {}): boolean { + const pending = this.pending.get(nonce) + if (!pending || pending.expiresAt <= this.now()) { + this.cancel(nonce) + return false + } + if (pending.admitted || pending.grantedRequestId !== undefined) { + return false + } + clearTimeout(pending.expiry) + pending.expiresAt = this.now() + 20000 + pending.expiry = setTimeout(() => this.cancel(nonce), 20000) + pending.expiry.unref?.() + pending.admitted = true + pending.readOwner = readOwner + pending.cleanup = cleanup + return true + } + + cancel(nonce: string): void { + const pending = this.pending.get(nonce) + this.pending.delete(nonce) + if (pending) { + clearTimeout(pending.expiry) + } + pending?.cleanup() + } + + clear(): void { + for (const nonce of this.pending.keys()) { + this.cancel(nonce) + } + } + + claim(body: unknown): boolean | 'pending' { + if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { + return false + } + const pending = this.pending.get(body.nonce) + if ( + !pending || + pending.expiresAt <= this.now() || + !('digest' in body) || + body.digest !== pending.digest + ) { + this.cancel(body.nonce) + return false + } + const requestId = 'requestId' in body ? body.requestId : undefined + if ( + requestId !== undefined && + (typeof requestId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(requestId)) + ) { + return false + } + if (pending.grantedRequestId !== undefined && pending.grantedRequestId !== requestId) { + return false + } + const owner = pending.readOwner() + if (owner === 'pending') { + return 'pending' + } + if (owner?.freshSpawn !== true || owner.firstUserInputAt !== null) { + this.cancel(body.nonce) + return false + } + if (requestId === undefined) { + this.cancel(body.nonce) + } else { + pending.grantedRequestId = requestId + } + return true + } +} diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts new file mode 100644 index 00000000000..69ff1aa14af --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import { + createOpenCodeStartupPromptInstaller, + installOpenCodeStartupPromptForLaunch +} from './opencode-startup-prompt-installer' +import { + captureOpenCodeSourceConfig, + applyOpenCodeStatusPluginEnv +} from '../ipc/pty/host-env/opencode-config' + +let root: string +let originalXdg: string | undefined +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'opencode-prompt-install-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = join(root, 'config') + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + vi.unstubAllEnvs() + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + rmSync(root, { recursive: true, force: true }) +}) +describe('OpenCode startup prompt installer', () => { + it('refuses a replaced status overlay instead of writing through its symlink or losing status hooks', () => { + const source = join(root, 'safe-source') + const foreign = join(root, 'foreign') + mkdirSync(source) + mkdirSync(foreign) + writeFileSync(join(foreign, 'untouched.txt'), 'foreign bytes') + const env: Record = { + OPENCODE_CONFIG_DIR: source, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'replaced-overlay' + } + const config = captureOpenCodeSourceConfig(env, join(root, 'profile')) + applyOpenCodeStatusPluginEnv( + 'pane', + env, + config, + { + launchAgent: 'opencode', + agentStatusHooksEnabled: true + }, + 'opencode' + ) + rmSync(env.OPENCODE_CONFIG_DIR, { recursive: true }) + symlinkSync(foreign, env.OPENCODE_CONFIG_DIR, 'junction') + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(false) + expect(env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE).toBeUndefined() + expect(readFileSync(join(foreign, 'untouched.txt'), 'utf8')).toBe('foreign bytes') + expect(existsSync(join(foreign, 'plugins'))).toBe(false) + }) + it.each(['opencode', 'opencode2'] as const)( + 'keeps real source provenance and composes the %s status and prompt in one final overlay', + (agent) => { + const source = join(root, 'real-source') + mkdirSync(join(source, 'plugins'), { recursive: true }) + writeFileSync(join(source, 'plugins', 'user.js'), 'user bytes') + writeFileSync(join(source, 'opencode.json'), '{"model":"selected/model"}') + const env: Record = { + OPENCODE_CONFIG_DIR: source, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'source-provenance' + } + expect(installOpenCodeStartupPromptForLaunch(env, false)).toBe(true) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + const captured = captureOpenCodeSourceConfig(env, join(root, 'profile')) + expect(captured.directory).toBe(source) + applyOpenCodeStatusPluginEnv( + 'pane', + env, + captured, + { + launchAgent: agent, + agentStatusHooksEnabled: true + }, + `${agent} --standalone` + ) + const statusOverlay = env.OPENCODE_CONFIG_DIR + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect(env.OPENCODE_CONFIG_DIR).toBe(statusOverlay) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + expect( + readFileSync(join(statusOverlay, 'plugins', `orca-${agent}-status.js`), 'utf8') + ).toContain('ORCA_STATUS_AGENT') + expect( + existsSync(join(statusOverlay, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(readFileSync(join(statusOverlay, 'plugins', 'user.js'), 'utf8')).toBe('user bytes') + const nested: Record = { + ...env, + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nested-source-provenance' + } + expect(installOpenCodeStartupPromptForLaunch(nested)).toBe(true) + expect(nested.OPENCODE_CONFIG_DIR).toBe(statusOverlay) + expect(nested.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + expect(readFileSync(join(source, 'plugins', 'user.js'), 'utf8')).toBe('user bytes') + expect(existsSync(join(source, 'plugins', `orca-${agent}-status.js`))).toBe(false) + } + ) + it.each(['inherited', 'explicit'] as const)( + 'preserves status and user plugins from the %s XDG config when launch env is sparse', + (selection) => { + const configHome = join(root, selection === 'explicit' ? 'selected-config' : 'config') + const config = join(configHome, 'opencode') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'plugins', 'orca-opencode-status.js'), 'status entry') + writeFileSync(join(config, 'plugins', 'user.js'), 'user entry') + writeFileSync(join(config, 'opencode.json'), '{"model":"selected/model"}') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'sparse-launch', + ...(selection === 'explicit' ? { XDG_CONFIG_HOME: configHome } : {}) + } + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect( + readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'), 'utf8') + ).toBe('status entry') + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + 'user entry' + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toContain( + 'selected/model' + ) + } + ) + + it("uses the execution owner's resolved environment instead of reintroducing a deleted ambient XDG home", () => { + const selected = join(root, 'resolved-config') + mkdirSync(join(selected, 'opencode', 'plugins'), { recursive: true }) + writeFileSync(join(selected, 'opencode', 'plugins', 'user.js'), 'resolved entry') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'resolved-launch' + } + expect(installOpenCodeStartupPromptForLaunch(env, false, { XDG_CONFIG_HOME: selected })).toBe( + true + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + 'resolved entry' + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + }) + + it.each(['inherited', 'explicit'] as const)( + 'preserves the %s OPENCODE_CONFIG_DIR ahead of the XDG default', + (selection) => { + const ambient = join(root, 'ambient-source') + const selected = join(root, 'selected-source') + for (const config of [ambient, selected]) { + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'plugins', 'user.js'), config) + } + vi.stubEnv('OPENCODE_CONFIG_DIR', ambient) + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'custom-config-launch', + ...(selection === 'explicit' ? { OPENCODE_CONFIG_DIR: selected } : {}) + } + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + selection === 'explicit' ? selected : ambient + ) + } + ) + + it('keeps a missing user config untouched and installs the launch into an owned overlay', () => { + const source = join(root, 'missing-user-config') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'private-launch', + OPENCODE_CONFIG_DIR: source, + OPENCODE_CONFIG_CONTENT: '{"model":"opencode/model"}' + } + installOpenCodeStartupPromptForLaunch(env) + expect(existsSync(source)).toBe(false) + expect(env.OPENCODE_CONFIG_DIR).toContain( + join(root, 'profile', 'opencode-startup-prompt-overlays') + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR) + expect(env.OPENCODE_CONFIG_CONTENT).toBe('{"model":"opencode/model"}') + expect( + existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'))).toBe( + false + ) + }) + it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { + const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') + expect(service.buildPtyEnv('pane')).toEqual({}) + const plugins = join(root, 'config', 'opencode', 'plugins') + expect(readFileSync(join(plugins, 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')).toBe( + 'prompt source' + ) + expect(existsSync(join(plugins, 'orca-opencode-startup-prompt.js'))).toBe(false) + expect(existsSync(join(plugins, 'orca-opencode-status.js'))).toBe(false) + expect(existsSync(join(root, 'profile', 'opencode-startup-prompt-hooks'))).toBe(false) + }) + it('preserves user config and plugins across source-scoped overlay refreshes', () => { + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + writeFileSync(join(config, 'plugins', 'user.js'), 'user source') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt')) + writeFileSync( + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), + 'user collision' + ) + let source = 'first prompt source' + const service = createOpenCodeStartupPromptInstaller(() => source) + const first = service.buildPtyEnv('pane-a', config).OPENCODE_CONFIG_DIR + expect(first).toBeDefined() + source = 'next prompt source' + expect(service.buildPtyEnv('pane-b', config).OPENCODE_CONFIG_DIR).toBe(first) + if (!first) { + throw new Error('Missing overlay') + } + expect( + readFileSync(join(first, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe(source) + expect(readFileSync(join(first, 'opencode.json'), 'utf8')).toContain('user/model') + expect(readFileSync(join(first, 'plugins', 'user.js'), 'utf8')).toBe('user source') + expect( + readFileSync(join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe('user collision') + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts new file mode 100644 index 00000000000..6ae79cc58d2 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -0,0 +1,82 @@ +import { OpenCodeHookService, openCodeHookService, openCode2HookService } from './hook-service' +import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' +import { join } from 'node:path' +import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' +import { isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { resolveOpenCodeSourceConfigDir } from '../ipc/pty/host-env/pi-agent' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' + +export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { + return new OpenCodeHookService({ + pluginFileName: `${OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY}.js`, + legacyHooksDir: 'opencode-startup-prompt-hooks', + overlayDir: 'opencode-startup-prompt-overlays', + pluginSource: source, + tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY + }) +} + +const installer = createOpenCodeStartupPromptInstaller(getOpenCodeStartupPromptSource) + +export function installOpenCodeStartupPromptForLaunch( + env: Record, + restoreAfterShellStartup = true, + sourceEnvironment: NodeJS.ProcessEnv = { ...process.env, ...env } +): boolean { + const nonce = env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (!nonce || env.ORCA_OPENCODE_PLUGIN_API !== 'v2') { + return false + } + const source = + resolveOpenCodeSourceConfigDir(env, sourceEnvironment) || + resolveOpenCodeConfigDirectory(sourceEnvironment) + const statusOwner = + env.ORCA_OPENCODE_AGENT === 'opencode2' ? openCode2HookService : openCodeHookService + const existing = + env.OPENCODE_CONFIG_DIR && env.OPENCODE_CONFIG_DIR === env.ORCA_OPENCODE_CONFIG_DIR + ? installer.installIntoSourceOverlay(env.OPENCODE_CONFIG_DIR, source, statusOwner) + : 'unmatched' + const overlay = + existing === 'installed' + ? env.OPENCODE_CONFIG_DIR + : existing === 'failed' + ? undefined + : installer.buildPtyEnv(nonce, source).OPENCODE_CONFIG_DIR + if ( + !overlay || + !isOverlayOpenCodePluginCurrent( + join(overlay, 'plugins', OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, 'tui.js'), + getOpenCodeStartupPromptSource() + ) + ) { + for (const key of [ + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV + ]) { + delete env[key] + } + return false + } + env.OPENCODE_CONFIG_DIR = overlay + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = source + if (restoreAfterShellStartup || existing === 'installed') { + env.ORCA_OPENCODE_CONFIG_DIR = overlay + } else { + delete env.ORCA_OPENCODE_CONFIG_DIR + } + return true +} + +export function ensureOpenCodeStartupPromptForLaunch(env: Record): void { + if (env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] && !installOpenCodeStartupPromptForLaunch(env)) { + throw new Error('Cannot prepare OpenCode startup prompt; launch was canceled.') + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.test.ts b/src/main/opencode/opencode-startup-prompt-owner.test.ts new file mode 100644 index 00000000000..40aa8eabe93 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../runtime/terminal-run-facts' +import { + reserveOpenCodeStartupPrompt, + commitPtyWithOpenCodePromptIntent +} from './opencode-startup-prompt-owner' + +const control = vi.hoisted(() => ({ + claim: (_body: unknown): boolean | 'pending' => false, + clear: () => {} +})) +const ownership = vi.hoisted(() => ({ + ptyOwnership: new Map(), + ptyIncarnationById: new Map() +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + setStartupPromptClaimListener: (claim: typeof control.claim, clear: () => void) => { + control.claim = claim + control.clear = clear + } + } +})) +vi.mock('../ipc/pty/provider/ownership-state', () => ownership) +beforeEach(() => { + control.clear() + ownership.ptyOwnership.clear() + ownership.ptyIncarnationById.clear() +}) + +function fixture() { + const facts = new TerminalRunFactsRegister() + const result = { id: 'owned', incarnationId: 'incarnation' } + let identityReady = false + let release = () => {} + const waiting = new Promise((resolve) => { + release = resolve + }) + const runtime = { + terminalRunFacts: facts, + readOpenCodeStartupPromptOwner: () => + identityReady ? facts.read(result.id, result.incarnationId) : ('pending' as const), + isPtyStopRequested: () => false, + subscribeToPtyExit: (_ptyId: string, _listener: () => void) => () => {} + } + ownership.ptyOwnership.set(result.id, null) + ownership.ptyIncarnationById.set(result.id, result.incarnationId) + const context = { + env: { + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nonce', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: 'digest', + ORCA_AGENT_LAUNCH_TOKEN: 'launch' + }, + deps: { runtime }, + result, + provider: { hasPty: () => true }, + args: {} + } + reserveOpenCodeStartupPrompt('nonce', 'digest') + const body = { nonce: 'nonce', digest: 'digest' } + return { + facts, + result, + context, + waiting, + release, + body, + admit: () => { + identityReady = true + } + } +} + +describe('native prompt admission after spawn commit', () => { + it('waits through delayed persistence and later runtime identity admission', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + expect(control.claim(f.body)).toBe('pending') + f.admit() + expect(control.claim(f.body)).toBe(true) + expect(control.claim(f.body)).toBe(false) + }) + + it('keeps pre-commit driving input sticky even if the draft is erased', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + + it('cancels a failed commit without allowing later admission', async () => { + const f = fixture() + await expect( + commitPtyWithOpenCodePromptIntent(f.context, async () => { + throw new Error('persistence rejected') + }) + ).rejects.toThrow('persistence rejected') + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + it.each(['incarnation', 'provider', 'stop', 'exit', 'clear'])( + 'invalidates granted replay after %s', + async (reason) => { + const f = fixture() + let exited = () => {} + const unsubscribe = vi.fn() + vi.spyOn(f.context.deps.runtime, 'subscribeToPtyExit').mockImplementation( + (_id, listener: () => void) => { + exited = listener + return unsubscribe + } + ) + await commitPtyWithOpenCodePromptIntent(f.context, async () => { + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.admit() + const body = { ...f.body, requestId: 'stable-operation' } + expect(control.claim(body)).toBe(true) + expect(control.claim(body)).toBe(true) + if (reason === 'incarnation') { + ownership.ptyIncarnationById.set(f.result.id, 'replacement') + } + if (reason === 'provider') { + vi.spyOn(f.context.provider, 'hasPty').mockReturnValue(false) + } + if (reason === 'stop') { + vi.spyOn(f.context.deps.runtime, 'isPtyStopRequested').mockReturnValue(true) + } + if (reason === 'exit') { + exited() + } + if (reason === 'clear') { + control.clear() + } + expect(control.claim(body)).toBe(false) + expect(unsubscribe).toHaveBeenCalledTimes(1) + f.facts.recordSpawnCommit(f.result) + expect(control.claim(body)).toBe(false) + } + ) + + it('retains sticky input cancellation after a lost grant response', async () => { + const f = fixture() + await commitPtyWithOpenCodePromptIntent(f.context, async () => { + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.admit() + const body = { ...f.body, requestId: 'stable-operation' } + expect(control.claim(body)).toBe(true) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(body)).toBe(false) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts new file mode 100644 index 00000000000..be1dacba25d --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -0,0 +1,111 @@ +import { agentHookServer } from '../agent-hooks/server' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV +} from '../../shared/opencode-startup-prompt' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +type OpenCodePromptRuntime = Pick< + OrcaRuntimeService, + | 'terminalRunFacts' + | 'readOpenCodeStartupPromptOwner' + | 'isPtyStopRequested' + | 'subscribeToPtyExit' +> +const claims = new OpenCodeStartupPromptClaims() + +export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boolean { + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + return claims.register(nonce, digest, () => 'pending') +} + +export async function commitPtyWithOpenCodePromptIntent( + context: { + env?: Record + spawnEnv?: Record + deps: { runtime?: OpenCodePromptRuntime } + result: PtySpawnResult + provider: Pick + args: { connectionId?: string | null } + }, + commit: () => Promise +): Promise { + const options = { + env: context.spawnEnv ?? context.env, + runtime: context.deps.runtime, + result: context.result, + provider: context.provider, + connectionId: context.args.connectionId + } + const facts = options.runtime?.terminalRunFacts + facts?.reserveSpawnCommit(options.result) + try { + const result = await commit() + bindOpenCodeStartupPromptOwner({ ...options, result }) + return result + } catch (error) { + const nonce = options.env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce) { + claims.cancel(nonce) + } + throw error + } finally { + facts?.discardSpawnCommit(options.result) + } +} + +export function bindOpenCodeStartupPromptOwner(options: { + env: Record | undefined + result: PtySpawnResult + runtime: OpenCodePromptRuntime | undefined + provider: Pick + connectionId?: string | null +}): void { + const { env, result, runtime, provider } = options + const nonce = env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + const digest = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const launchToken = env?.ORCA_AGENT_LAUNCH_TOKEN + const incarnation = result.incarnationId + if ( + options.connectionId || + !runtime || + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isPtyIncarnationId(incarnation) || + !nonce || + !digest || + !launchToken + ) { + if (nonce) { + claims.cancel(nonce) + } + return + } + let unsubscribe = () => {} + if ( + claims.admit( + nonce, + () => { + if ( + ptyOwnership.get(result.id) !== null || + ptyIncarnationById.get(result.id) !== incarnation || + provider.hasPty?.(result.id) !== true || + runtime.isPtyStopRequested(result.id) + ) { + return null + } + return runtime.readOpenCodeStartupPromptOwner(result.id, incarnation, launchToken) + }, + () => unsubscribe() + ) + ) { + unsubscribe = runtime.subscribeToPtyExit(result.id, () => claims.cancel(nonce)) + } +} diff --git a/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts b/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts new file mode 100644 index 00000000000..8f4759957a1 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID +} from '../runtime/agent-transcript-pane-test-harness' + +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') } +})) + +async function fixture(launchAgent?: 'opencode' | 'opencode2' | 'zcode') { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '', + ...(launchAgent ? { launchAgent } : {}) + }) + runtime.terminalRunFacts.recordSpawnCommit({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }) + const read = ( + ptyId = TRANSCRIPT_PANE_PTY_ID, + incarnation = 'inc-1', + token = 'transcript-launch' + ) => runtime.readOpenCodeStartupPromptOwner(ptyId, incarnation, token) + return { runtime, read } +} + +describe('runtime-owned startup prompt identity', () => { + it('keeps launch admission pending before runtime identity is assigned', async () => { + const f = await fixture() + expect(f.read()).toBe('pending') + expect(f.read('missing')).toBeNull() + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull() + }) + + it.each(['opencode', 'opencode2'] as const)( + 'reads only the admitted %s launch', + async (agent) => { + const f = await fixture(agent) + expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-1', 'another-launch')).toBeNull() + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull() + f.runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', 'x', 123) + expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: 123 }) + } + ) + + it('denies another agent even with the exact incarnation and launch token', async () => { + expect((await fixture('zcode')).read()).toBeNull() + }) + it('refuses same-ID replay after the execution owner or launch token changes', async () => { + const f = await fixture('opencode2') + const claims = new OpenCodeStartupPromptClaims() + claims.register('owned-operation', 'digest', () => f.read()) + const body = { nonce: 'owned-operation', digest: 'digest', requestId: 'stable-operation' } + expect(claims.claim(body)).toBe(true) + expect(claims.claim(body)).toBe(true) + await f.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0, 'inc-1') + f.runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, 'wt-1', null, { + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + incarnationId: 'inc-2', + agentLaunchAuthority: { launchToken: 'replacement-launch', launchAgent: 'opencode2' } + }) + f.runtime.terminalRunFacts.recordSpawnCommit({ + id: TRANSCRIPT_PANE_PTY_ID, + incarnationId: 'inc-2' + }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'replacement-launch')).toEqual({ + freshSpawn: true, + firstUserInputAt: null + }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'transcript-launch')).toBeNull() + expect(claims.claim(body)).toBe(false) + claims.clear() + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts new file mode 100644 index 00000000000..2e505ee2a8f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -0,0 +1,761 @@ +import { EventEmitter } from 'node:events' +import { createServer } from 'node:http' +import { createHash } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' + +type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise> } } +const prompt = 'exact startup brief\nwith unicode é' +const digest = createHash('sha256').update(prompt).digest('hex') +let dir: string +let setup: PluginModule['default']['setup'] +let claim: ReturnType + +class Editor extends EventEmitter { + traits: { owner: string; role: string; capture: string[]; status?: string } = { + owner: 'opencode', + role: 'prompt', + capture: ['tab'] + } + plainText = '' + focused = true + insertText(text: string) { + this.replace(this.plainText + text) + } + replace(text: string) { + this.plainText = text + this.emit('line-info-change') + } +} + +type FixtureLocation = { directory: string; workspaceID?: string } + +function fixture() { + const editor = new Editor() + const input = new EventEmitter() + const memory = { settled: false, expiresAt: Date.now() + 20000 } + const route = { type: 'home' } + const agent = vi.fn((): unknown[] | undefined => [{}]) + const model = vi.fn((): unknown[] | undefined => [{}]) + const sync = vi.fn(async (_location: FixtureLocation) => {}) + const dispatch = vi.fn(() => editor.replace('')) + const ctx = { + app: { version: '2.0.16' }, + renderer: { keyInput: input, currentFocusedEditor: editor }, + storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] }, + keymap: { dispatch }, + ui: { router: { current: () => route } }, + location: { directory: '/private' }, + data: { location: { sync, agent: { list: agent }, model: { list: model } } } + } + return { ctx, editor, input, memory, route, agent, model, sync, dispatch } +} + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-prompt-')) + const path = join(dir, 'prompt.mjs') + writeFileSync(path, getOpenCodeStartupPromptSource()) + const module: PluginModule = await import(pathToFileURL(path).href) + setup = module.default.setup + vi.useFakeTimers() + vi.stubEnv(OPENCODE_STARTUP_PROMPT_SHA256_ENV, digest) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_BODY_ENV, prompt) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_NONCE_ENV, 'single-use-nonce') + const endpoint = join(dir, 'endpoint.cmd') + writeFileSync( + endpoint, + 'set ORCA_AGENT_HOOK_PORT=12345\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n' + ) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, endpoint) + claim = vi.fn(async () => ({ ok: true, json: async () => ({ allowed: true }) })) + vi.stubGlobal('fetch', claim) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('installed-version native prompt intent plugin', () => { + it('waits for the current home location after the startup directory changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private/home/private-folder' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let releaseStartup = () => {} + let releaseHome = () => {} + let selectedModel = 'opencode/mimo-v2.6-flash-free' + const selections: string[] = [] + f.sync.mockImplementation( + (target) => + new Promise((resolve) => { + if (target.directory.endsWith('/private-folder')) { + releaseStartup = resolve + } else { + releaseHome = () => { + selectedModel = 'private-proof/model-a' + resolve() + } + } + }) + ) + f.dispatch.mockImplementation(() => { + selections.push(selectedModel) + f.editor.replace('') + }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private/home' } + releaseStartup() + await vi.advanceTimersByTimeAsync(300) + expect(claim).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + releaseHome() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(selections).toEqual(['private-proof/model-a']) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('waits for a concrete location before starting authoritative hydration', async () => { + const f = fixture() + let location: FixtureLocation | undefined + Object.defineProperty(f.ctx, 'location', { get: () => location }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(300) + expect(f.sync).not.toHaveBeenCalled() + expect(claim).not.toHaveBeenCalled() + location = { directory: '/private/home' } + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(location) + } finally { + await dispose() + } + }) + + it('keeps readiness scoped to the workspace as well as the directory', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private', workspaceID: 'first' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let release = () => {} + f.sync.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private', workspaceID: 'second' } + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('refuses a granted claim after the composer location changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let grant = () => {} + claim.mockImplementation( + () => + new Promise((resolve) => { + grant = () => resolve({ ok: true, json: async () => ({ allowed: true }) }) + }) + ) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + location = { directory: '/private/other' } + grant() + await vi.advanceTimersByTimeAsync(500) + expect(insert).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + } finally { + await dispose() + } + }) + + it('waits for authoritative location config before claiming or selecting a model', async () => { + const f = fixture() + let configuredModel = 'unavailable-fallback' + let release = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + release = () => { + configuredModel = 'configured-model' + resolve() + } + }) + ) + const selections: string[] = [] + f.dispatch.mockImplementation(() => { + selections.push(configuredModel) + f.editor.replace('') + }) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(insert).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(f.ctx.location) + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(selections).toEqual(['configured-model']) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.sync).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it.each(['keypress', 'paste', 'edit', 'route', 'expiry', 'dispose', 'editor', 'focus'])( + 'cancels delayed location hydration on %s before any claim', + async (reason) => { + const f = fixture() + let release = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + expect(claim).not.toHaveBeenCalled() + if (reason === 'keypress' || reason === 'paste') { + f.input.emit(reason) + } + if (reason === 'edit') { + f.editor.replace('typed') + f.editor.replace('') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiry') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + if (reason === 'editor') { + f.ctx.renderer.currentFocusedEditor = new Editor() + } + if (reason === 'focus') { + f.editor.focused = false + } + await vi.advanceTimersByTimeAsync(100) + release() + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.editor.plainText).toBe('') + if (reason !== 'focus') { + expect(f.memory.settled).toBe(true) + } + } finally { + await dispose() + } + expect(f.input.listenerCount('keypress')).toBe(0) + expect(f.editor.listenerCount('line-info-change')).toBe(0) + } + ) + + it('fails closed when authoritative location sync rejects', async () => { + const f = fixture() + f.sync.mockRejectedValue(new Error('location unavailable')) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.memory.settled).toBe(true) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.input.listenerCount('keypress')).toBe(0) + await dispose() + }) + + it('fails closed when authoritative location sync is missing', async () => { + const f = fixture() + const { sync: _sync, ...location } = f.ctx.data.location + const dispose = await setup({ ...f.ctx, data: { location } }) + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.input.listenerCount('keypress')).toBe(0) + await dispose() + }) + + it.each(['non-ok', 'json-rejected'])('cancels unread %s claim bodies', async (reason) => { + const cancelled = vi.fn() + const response = cancelTrackingResponse(reason === 'non-ok' ? 503 : 200, cancelled) + if (reason === 'json-rejected') { + vi.spyOn(response, 'json').mockRejectedValue(new Error('decoder rejected')) + } + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(cancelled).toHaveBeenCalled()) + expect(f.memory.settled).toBe(false) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + expect(f.memory.settled).toBe(true) + }) + + it('consumes an allowed claim body before dispatching the prompt', async () => { + const response = Response.json({ allowed: true }) + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(response.bodyUsed).toBe(true) + await dispose() + }) + + it('consumes malformed JSON and retries without delivering until expiry', async () => { + const response = new Response('{invalid', { status: 200 }) + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => { + expect(response.bodyUsed).toBe(true) + expect(claim.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + expect(f.memory.settled).toBe(false) + f.memory.expiresAt = Date.now() + await vi.advanceTimersByTimeAsync(100) + expect(response.bodyUsed).toBe(true) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => { + const f = fixture() + if (kind === 'dialog') { + f.editor.traits.role = 'dialog' + } + if (kind === 'shell') { + f.editor.traits.status = 'SHELL' + } + if (kind === 'autocomplete') { + f.editor.traits.capture = ['escape', 'navigate', 'submit', 'tab'] + } + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('retries pending admission, then submits once', async () => { + claim.mockResolvedValueOnce({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + }) + + it('cancels pending admission on input without retrying a consumed denial', async () => { + claim.mockResolvedValue({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + f.input.emit('keypress', { name: 'x' }) + await vi.advanceTimersByTimeAsync(1000) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('preserves typing that predates plugin setup without requesting owner permission', async () => { + const f = fixture() + f.editor.replace('early typing') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('early typing') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('waits for catalogs and settles before a single dispatch', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.memory.settled).toBe(true) + f.editor.replace(prompt) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['keypress', 'paste'])('cancels on physical %s before catalogs finish', async (event) => { + const f = fixture() + f.agent.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + f.input.emit(event) + f.agent.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it('cancels a changed draft even when it is restored before the next tick', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + f.editor.replace('edited') + f.editor.replace('') + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('cancels pending intent on route changes, expiration and disposal', async () => { + for (const reason of ['route', 'expiration', 'dispose']) { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiration') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + await vi.advanceTimersByTimeAsync(100) + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + expect(f.input.listenerCount('keypress')).toBe(0) + } + }) + + it('leaves unverified versions and mismatched drafts unsubmitted', async () => { + const f = fixture() + f.ctx.app.version = '2.0.17' + await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.ctx.app.version = '2.0.16' + f.editor.replace('another brief') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['canceled', 'unavailable'])( + 'fails closed when the execution owner is %s', + async (reason) => { + claim.mockImplementation(async () => { + if (reason === 'unavailable') { + throw new Error('contact lost') + } + return { ok: true, json: async () => ({ allowed: false }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + if (reason === 'unavailable') { + await vi.waitFor(() => expect(claim).toHaveBeenCalled()) + expect(f.memory.settled).toBe(false) + f.memory.expiresAt = Date.now() + await vi.advanceTimersByTimeAsync(100) + } + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + await dispose() + } + ) + + it('rechecks physical cancellation after the owner response', async () => { + const f = fixture() + claim.mockImplementation(async () => { + f.input.emit('keypress') + return { ok: true, json: async () => ({ allowed: true }) } + }) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('settles before insertion and never repeats dispatch when the draft is retained', async () => { + const f = fixture() + f.dispatch.mockImplementation(() => {}) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(1500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(f.memory.settled).toBe(true) + expect(f.editor.plainText).toBe(prompt) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['before-grant', 'after-grant', 'timeout-after-grant'])( + 'recovers actual HTTP response loss %s exactly once', + async (phase) => { + vi.useRealTimers() + vi.unstubAllGlobals() + const claims = new OpenCodeStartupPromptClaims() + claims.register('single-use-nonce', digest, () => ({ + freshSpawn: true, + firstUserInputAt: null + })) + let requests = 0 + const bodies: unknown[] = [] + const grants: (boolean | 'pending')[] = [] + let heldResponse: ReturnType | undefined + const server = createServer(async (request, response) => { + let text = '' + for await (const chunk of request) { + text += chunk.toString() + } + const body: unknown = JSON.parse(text) + bodies.push(body) + requests++ + if (requests === 1 && phase === 'before-grant') { + response.writeHead(503).end('temporarily unavailable') + return + } + const allowed = claims.claim(body) + grants.push(allowed) + if (requests === 1 && phase === 'after-grant') { + response.destroy() + return + } + response.writeHead(200, { 'content-type': 'application/json' }) + if (requests === 1 && phase === 'timeout-after-grant') { + response.write('{"allowed":') + heldResponse = setTimeout(() => response.end('true}'), 1300) + return + } + response.end(JSON.stringify({ allowed: allowed === true, pending: allowed === 'pending' })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('missing loopback address') + } + writeFileSync( + join(dir, 'endpoint.cmd'), + `set ORCA_AGENT_HOOK_PORT=${address.port}\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n` + ) + const f = fixture() + const dispose = await setup(f.ctx) + try { + await vi.waitFor( + () => + expect( + f.dispatch, + JSON.stringify({ phase, requests, bodies, grants }) + ).toHaveBeenCalledTimes(1), + { timeout: 3000 } + ) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(requests).toBe(2) + expect(grants).toEqual(phase === 'before-grant' ? [true] : [true, true]) + expect(bodies[1]).toEqual(bodies[0]) + expect(bodies[0]).toHaveProperty('requestId', expect.any(String)) + expect(f.memory.settled).toBe(true) + expect(f.editor.plainText).toBe('') + } finally { + await dispose() + claims.clear() + clearTimeout(heldResponse) + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + } + ) + + it.each([408, 429, 503])( + 'retries transient HTTP %s with one stable operation ID', + async (status) => { + claim.mockResolvedValueOnce({ ok: false, status }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + const firstBody = JSON.parse(claim.mock.calls[0][1].body) + expect(firstBody.requestId).toMatch(/^[a-f0-9-]{36}$/) + expect(JSON.parse(claim.mock.calls[1][1].body)).toEqual(firstBody) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + } + ) + + it.each([401, 403, 404])('settles HTTP %s denial without retrying', async (status) => { + claim.mockResolvedValue({ ok: false, status }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['input', 'route', 'dispose', 'expiry', 'editor'])( + 'rejects late grants after %s changes', + async (reason) => { + const f = fixture() + let release = (_response: unknown) => {} + claim.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + if (reason === 'input') { + f.input.emit('paste') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'dispose') { + await dispose() + } + if (reason === 'expiry') { + f.memory.expiresAt = Date.now() + } + if (reason === 'editor') { + f.ctx.renderer.currentFocusedEditor = new Editor() + } + release({ ok: true, json: async () => ({ allowed: true }) }) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.editor.plainText).toBe('') + await dispose() + } + ) + + it.each(['input', 'route', 'dispose'])( + 'ends delivery when %s changes during insertion', + async (reason) => { + const f = fixture() + let dispose = async () => {} + const insert = f.editor.insertText.bind(f.editor) + vi.spyOn(f.editor, 'insertText').mockImplementation((text) => { + expect(f.memory.settled).toBe(true) + insert(text) + if (reason === 'input') { + f.input.emit('keypress') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'dispose') { + void dispose() + } + }) + dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + } + ) + + it.each(['allow', 'lost-response'])( + 'degrades safely against a legacy host with %s', + async (reason) => { + const claims = new OpenCodeStartupPromptClaims() + claims.register('single-use-nonce', digest, () => ({ + freshSpawn: true, + firstUserInputAt: null + })) + let lost = false + claim.mockImplementation(async (_url, init) => { + const body = JSON.parse(init.body) + const allowed = claims.claim({ nonce: body.nonce, digest: body.digest }) + if (reason === 'lost-response' && !lost) { + lost = true + throw new Error('legacy grant response lost') + } + return { ok: true, json: async () => ({ allowed }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 0) + expect(claim).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 2) + expect(f.editor.plainText).toBe('') + claims.clear() + await dispose() + } + ) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts new file mode 100644 index 00000000000..4bb37861102 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -0,0 +1,152 @@ +import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + OPENCODE_STARTUP_PROMPT_BODY_ENV +} from '../../shared/opencode-startup-prompt' + +export function getOpenCodeStartupPromptSource(): string { + return String.raw` +const parseEndpoint = ${parseAgentHookEndpointFile.toString()}; +const cancelUnreadResponseBody = ${cancelUnreadResponseBody.toString()}; +async function claimStartupPrompt(nonce, digest, endpoint, requestId) { + let response; + try { + const { readFile, stat } = await import("node:fs/promises"); + if ((await stat(endpoint)).size > 4096) return false; + const coords = parseEndpoint(await readFile(endpoint, "utf8")); + const port = Number(coords.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return false; + response = await fetch("http://127.0.0.1:" + port + "${OPENCODE_STARTUP_PROMPT_CLAIM_PATH}", { + method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token }, + body: JSON.stringify({ nonce, digest, requestId }), signal: AbortSignal.timeout(1000) + }); + if (!response.ok) return response.status === 408 || response.status === 429 || response.status >= 500 ? "pending" : false; + const result = await response.json(); + return result.allowed === true ? true : result.pending === true ? "pending" : false; + } catch { + // A lost grant response can be replayed with the same operation ID until expiry. + return "pending"; + } finally { + if (response) await cancelUnreadResponseBody(response); + } +} +async function submitStartupPrompt(ctx) { + const noop = async () => {}; + const digest = process.env.${OPENCODE_STARTUP_PROMPT_SHA256_ENV}; + const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV}; + const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV}; + const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV}; + if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop; + const input = ctx.renderer?.keyInput; + if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" || + typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" || + typeof ctx.ui?.router?.current !== "function" || + typeof ctx.data?.location?.sync !== "function" || + typeof ctx.data?.location?.agent?.list !== "function" || + typeof ctx.data?.location?.model?.list !== "function") return noop; + const [memory, setMemory] = ctx.storage.memory("startup-prompt", { + initial: { settled: false, expiresAt: Date.now() + 20000 } + }); + if (memory.settled) return noop; + let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, hydrating = false, readyLocation; + // Home can change location after plugin setup. + const locationKey = (location) => typeof location?.directory === "string" && location.directory ? + JSON.stringify([location.directory, location.workspaceID]) : undefined; + const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && + candidate.traits.role === "prompt" && !candidate.traits.status && + candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; + const matches = (candidate) => typeof candidate?.plainText === "string" && + createHash("sha256").update(candidate.plainText).digest("hex") === digest; + const cleanup = () => { + clearInterval(timer); + input.off("keypress", cancel); + input.off("paste", cancel); + editor?.off("line-info-change", changed); + }; + const settle = () => { + setMemory((draft) => { draft.settled = true; }); + cleanup(); + }; + const cancel = () => { canceled = true; settle(); }; + // Any edit before delivery ends this startup operation. + const changed = () => { if (seen && editor.plainText !== "") settle(); }; + input.on("keypress", cancel); + input.on("paste", cancel); + const dispose = async () => { disposed = true; settle(); }; + try { + const crypto = await import("node:crypto"); + createHash = crypto.createHash; + setMemory((draft) => { draft.requestId ??= crypto.randomUUID(); }); + requestId = memory.requestId; + if (createHash("sha256").update(prompt).digest("hex") !== digest) { await dispose(); return noop; } + if (memory.settled) return dispose; + timer = setInterval(async () => { + if (disposed || memory.settled) return; + try { + if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); + const current = ctx.renderer.currentFocusedEditor; + if (!isComposer(current)) { if (seen) settle(); return; } + if (editor !== current) { + if (seen) return settle(); + editor?.off("line-info-change", changed); + editor = current; + editor?.on("line-info-change", changed); + } + if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return; + if (editor.plainText !== "") return settle(); + seen = true; + const location = ctx.location; + const key = locationKey(location); + if (!key) return; + if (readyLocation !== key) { + readyLocation = undefined; + if (!hydrating) { + hydrating = true; + const ref = { directory: location.directory, workspaceID: location.workspaceID }; + void ctx.data.location.sync(ref).then(() => { + hydrating = false; + if (!disposed && !memory.settled && locationKey(ctx.location) === key) readyLocation = key; + }, settle); + } + return; + } + const agents = ctx.data.location.agent.list(location); + const models = ctx.data.location.model.list(location); + if (!editor.focused || !agents?.length || !models?.length) return; + if (claiming) return; + claiming = true; + const allowed = await claimStartupPrompt(nonce, digest, endpoint, requestId); + claiming = false; + if (allowed === "pending") return; + if (!allowed) return settle(); + if (disposed || memory.settled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || !isComposer(editor) || editor.plainText !== "") return settle(); + setMemory((draft) => { draft.settled = true; }); + clearInterval(timer); + editor.off("line-info-change", changed); + try { + editor.insertText(prompt); + if (disposed || canceled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || !isComposer(editor) || !matches(editor)) return; + ctx.keymap.dispatch("prompt.submit"); + } finally { cleanup(); } + } catch { settle(); } + }, 100); + timer.unref?.(); + return dispose; + } catch { + settle(); + return noop; + } +} +export default { id: "orca-opencode-startup-prompt", setup: submitStartupPrompt }; +`.trimStart() +} diff --git a/src/main/opencode/opencode-wsl-probe-environment.test.ts b/src/main/opencode/opencode-wsl-probe-environment.test.ts new file mode 100644 index 00000000000..864321c6001 --- /dev/null +++ b/src/main/opencode/opencode-wsl-probe-environment.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { seedWslGuestEnvironmentForTests } from '../wsl/wsl-guest-environment' + +const run = vi.hoisted(() => vi.fn()) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: run })) +vi.mock('../wsl/wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'wsl.exe' })) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: { endpointFilePath: null } })) + +beforeEach(() => { + run.mockReset().mockResolvedValue({ code: 0, stdout: '2.0.16', timedOut: false }) + seedWslGuestEnvironmentForTests('probe-deletion', { + path: '/usr/bin', + home: '/private/guest', + envBinary: '/usr/bin/env' + }) + vi.stubEnv('DELETED_CREDENTIAL', 'dummy-host-value') + vi.stubEnv('OPENCODE_CONFIG_DIR', 'C:\\dummy\\config') + vi.stubEnv('KEEP', 'C:\\keep') + vi.stubEnv('EMPTY', '') + vi.stubEnv('WSLENV', 'DELETED_CREDENTIAL/u:OPENCODE_CONFIG_DIR/p:KEEP/p:EMPTY/u') +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('OpenCode WSL version-probe deletion boundary', () => { + it.each([ + { deleted: ['DELETED_CREDENTIAL', 'OPENCODE_CONFIG_DIR'], kept: ['KEEP/p', 'EMPTY/u'] }, + { deleted: ['WSLENV'], kept: [] } + ])( + 'does not restore deleted keys through the inherited carrier: $deleted', + async ({ deleted, kept }) => { + await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete: [...deleted], + isFreshLaunch: true, + wsl: { distro: 'probe-deletion' }, + cwd: `/private/${deleted.join('-')}` + }) + const hostEnv = run.mock.calls[0]?.[0].env + expect(hostEnv).toBeDefined() + const tokens = String(hostEnv.WSLENV).split(':').filter(Boolean) + expect(tokens).not.toContain('DELETED_CREDENTIAL/u') + expect(tokens).not.toContain('OPENCODE_CONFIG_DIR/p') + expect(tokens).toEqual(expect.arrayContaining(kept)) + // The host value may exist; its carrier must not import it into the guest. + expect(hostEnv.DELETED_CREDENTIAL).toBe('dummy-host-value') + } + ) + + it('shares a sanitized concurrent probe without losing kept flags or empty values', async () => { + const options = { + command: 'opencode', + env: {}, + envToDelete: ['DELETED_CREDENTIAL', 'OPENCODE_CONFIG_DIR'], + isFreshLaunch: true, + wsl: { distro: 'probe-deletion' }, + cwd: '/private/concurrent' + } + await Promise.all([ + prepareOpenCodePtyLaunch({ ...options, envToDelete: [...options.envToDelete] }), + prepareOpenCodePtyLaunch({ ...options, envToDelete: [...options.envToDelete] }) + ]) + expect(run).toHaveBeenCalledTimes(1) + const hostEnv = run.mock.calls[0]?.[0].env + expect(hostEnv.WSLENV).toContain('KEEP/p') + expect(hostEnv.WSLENV).toContain('EMPTY/u') + expect(hostEnv.WSLENV).not.toContain('DELETED_CREDENTIAL') + expect(hostEnv.WSLENV).not.toContain('OPENCODE_CONFIG_DIR') + expect(hostEnv.EMPTY).toBe('') + }) +}) diff --git a/src/main/opencode/overlay-dir-gc-lifecycle.test.ts b/src/main/opencode/overlay-dir-gc-lifecycle.test.ts new file mode 100644 index 00000000000..6149c1094fe --- /dev/null +++ b/src/main/opencode/overlay-dir-gc-lifecycle.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { OpenCodeDirGcLifecycle } from './overlay-dir-gc-lifecycle' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it('schedules one delayed sweep without holding the app open', async () => { + vi.useFakeTimers() + const timeout = vi.spyOn(globalThis, 'setTimeout') + const lifecycle = new OpenCodeDirGcLifecycle(() => '/unused-test-root', 'unused-plugin.js') + const run = vi.spyOn(lifecycle, 'run').mockResolvedValue({ + scanned: 0, + removed: 0, + failed: 0, + keptReferenced: 0, + keptYoung: 0, + keptSourcePresent: 0, + keptUnverifiable: 0 + }) + const inventory = vi.fn(async () => []) + lifecycle.schedule(inventory) + lifecycle.schedule(inventory) + expect(timeout.mock.results[0]?.value.hasRef()).toBe(false) + await vi.advanceTimersByTimeAsync(179999) + expect(run).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(run).toHaveBeenCalledExactlyOnceWith(inventory) +}) diff --git a/src/main/opencode/overlay-dir-gc-lifecycle.ts b/src/main/opencode/overlay-dir-gc-lifecycle.ts new file mode 100644 index 00000000000..df84b28bf18 --- /dev/null +++ b/src/main/opencode/overlay-dir-gc-lifecycle.ts @@ -0,0 +1,46 @@ +import { OPENCODE_CONFIG_DIR_ENV_KEYS } from './legacy-shared-config-dir' +import { sweepOrphanedOpenCodeDirs, type OpenCodeDirGcResult } from './overlay-dir-gc' + +type ReadLivePtyIds = () => Promise + +export class OpenCodeDirGcLifecycle { + private readonly references = new Set() + private scheduled = false + + constructor( + private readonly getRoot: () => string, + private readonly pluginFileName: string + ) {} + + reference(directory: string): void { + this.references.add(directory) + } + + schedule(readLivePtyIds: ReadLivePtyIds, delayMs = 3 * 60_000): void { + if (this.scheduled) { + return + } + this.scheduled = true + const timer = setTimeout(() => { + void this.run(readLivePtyIds).catch((error) => { + console.warn('[OpenCode] Overlay cleanup skipped:', error) + }) + }, delayMs) + timer.unref() + } + + async run(readLivePtyIds: ReadLivePtyIds): Promise { + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + const value = process.env[key] + if (value) { + this.references.add(value) + } + } + return sweepOrphanedOpenCodeDirs({ + overlayRoot: this.getRoot(), + pluginFileName: this.pluginFileName, + referencedConfigDirs: this.references, + readLivePtyIds + }) + } +} diff --git a/src/main/opencode/overlay-dir-gc.test.ts b/src/main/opencode/overlay-dir-gc.test.ts new file mode 100644 index 00000000000..e2472477154 --- /dev/null +++ b/src/main/opencode/overlay-dir-gc.test.ts @@ -0,0 +1,227 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' +import { OPENCODE_DIR_GC_MIN_AGE_MS, sweepOrphanedOpenCodeDirs } from './overlay-dir-gc' +import { ORCA_OPENCODE_PLUGIN_FILE, sourceOverlayDirName } from './overlay-dir-names' + +let root: string +let overlays: string +const now = Date.now() + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-overlay-gc-'))) + overlays = join(root, 'opencode-config-overlays') + mkdirSync(overlays) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ]) { + vi.stubEnv(key, '') + } + setAppEnvironment({ + getPath: () => root, + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +function age(directory: string): void { + const old = new Date(now - OPENCODE_DIR_GC_MIN_AGE_MS * 2) + for (const suffix of [ + '', + OPENCODE_OVERLAY_MANIFEST_FILE, + 'plugins', + join('plugins', ORCA_OPENCODE_PLUGIN_FILE) + ]) { + utimesSync(join(directory, suffix), old, old) + } +} + +function createOverlay(name: string): { source: string; directory: string } { + const source = join(root, name) + const directory = join(overlays, sourceOverlayDirName(source)) + mkdirSync(join(directory, 'plugins'), { recursive: true }) + writeFileSync(join(directory, 'plugins', ORCA_OPENCODE_PLUGIN_FILE), 'export default {}') + writeFileSync( + join(directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: source + }) + ) + age(directory) + return { source, directory } +} + +function sweep(extra: Partial[0]> = {}) { + return sweepOrphanedOpenCodeDirs({ + overlayRoot: overlays, + pluginFileName: ORCA_OPENCODE_PLUGIN_FILE, + referencedConfigDirs: new Set(), + readLivePtyIds: async () => [], + now, + yieldBetweenRemovals: async () => {}, + ...extra + }) +} + +it('collects only an old, owned missing source while retaining active and reusable sources', async () => { + const retired = createOverlay('retired') + const active = createOverlay('active') + const reusable = createOverlay('reusable') + mkdirSync(active.source) + mkdirSync(reusable.source) + const result = await sweep() + expect(result.removed).toBe(1) + expect(result.keptSourcePresent).toBe(2) + expect(existsSync(retired.directory)).toBe(false) + expect(existsSync(active.directory)).toBe(true) + expect(existsSync(reusable.directory)).toBe(true) +}) + +it.each([null, ['surviving-daemon-pty']] as const)( + 'keeps a retired source with an empty fresh-process reference cache when inventory is %s', + async (ids) => { + const retired = createOverlay('retired') + expect((await sweep({ readLivePtyIds: async () => ids })).removed).toBe(0) + expect(existsSync(retired.directory)).toBe(true) + } +) + +it('preserves a candidate when owning-host inventory fails', async () => { + const retired = createOverlay('retired') + expect( + ( + await sweep({ + readLivePtyIds: async () => { + throw new Error('host unavailable') + } + }) + ).keptUnverifiable + ).toBe(1) + expect(existsSync(retired.directory)).toBe(true) +}) + +it.each(['service.json', 'service-local.json'])( + 'preserves %s without guessing service process death', + async (file) => { + const retired = createOverlay('retired') + writeFileSync(join(retired.directory, file), '{}') + age(retired.directory) + const inventory = vi.fn(async () => []) + expect((await sweep({ readLivePtyIds: inventory })).keptUnverifiable).toBe(1) + expect(inventory).not.toHaveBeenCalled() + expect(existsSync(retired.directory)).toBe(true) + } +) + +it('keeps a young candidate and refreshes reference protection after an asynchronous inventory', async () => { + const young = createOverlay('young') + utimesSync( + join(young.directory, 'plugins', ORCA_OPENCODE_PLUGIN_FILE), + new Date(now), + new Date(now) + ) + const retired = createOverlay('retired') + const references = new Set() + const result = await sweep({ + referencedConfigDirs: references, + readLivePtyIds: async () => { + references.add(join(retired.directory, 'plugins')) + return [] + } + }) + expect(result.keptYoung).toBe(1) + expect(result.keptReferenced).toBe(1) + expect(existsSync(retired.directory)).toBe(true) +}) + +it('does not follow a replaced overlay root or a source ancestor link', async () => { + const retired = createOverlay('retired') + const target = join(root, 'elsewhere') + mkdirSync(target) + symlinkSync(target, retired.source, process.platform === 'win32' ? 'junction' : 'dir') + expect((await sweep()).removed).toBe(0) + rmSync(overlays, { recursive: true }) + symlinkSync(target, overlays, process.platform === 'win32' ? 'junction' : 'dir') + expect((await sweep()).scanned).toBe(0) + expect(existsSync(target)).toBe(true) +}) + +it('retains unowned names, ambiguous old manifests and mismatched source hashes', async () => { + for (const name of ['shared', '123', 'human']) { + mkdirSync(join(overlays, name)) + } + const ambiguous = createOverlay('ambiguous') + writeFileSync( + join(ambiguous.directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: [] }) + ) + const mismatch = createOverlay('mismatch') + writeFileSync( + join(mismatch.directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: [], sourceConfigDir: root }) + ) + expect((await sweep()).keptUnverifiable).toBe(5) +}) + +it('counts failed deletion attempts toward the bound and yields between them', async () => { + for (let i = 0; i < 4; i += 1) { + createOverlay(`retired-${i}`) + } + const remove = vi.fn(() => { + throw new Error('busy') + }) + const yieldBetween = vi.fn(async () => {}) + const result = await sweep({ + maxRemovals: 2, + removeTree: remove, + yieldBetweenRemovals: yieldBetween + }) + expect(result.failed).toBe(2) + expect(remove).toHaveBeenCalledTimes(2) + expect(yieldBetween).toHaveBeenCalledTimes(2) +}) + +it('retains handed-out and inherited source references through clearPty', async () => { + const source = join(root, 'source') + mkdirSync(source) + writeFileSync(join(source, 'opencode.json'), '{}') + const service = new OpenCodeHookService(() => 'export default {}') + const directory = service.buildPtyEnv('pane', source).OPENCODE_CONFIG_DIR + if (!directory) { + throw new Error('Expected overlay') + } + rmSync(source, { recursive: true }) + age(directory) + service.clearPty('pane') + expect((await service.configDirGc.run(async () => [])).keptReferenced).toBe(1) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', source) + expect((await new OpenCodeHookService().configDirGc.run(async () => [])).keptReferenced).toBe(1) +}) diff --git a/src/main/opencode/overlay-dir-gc.ts b/src/main/opencode/overlay-dir-gc.ts new file mode 100644 index 00000000000..6961c0c3c1a --- /dev/null +++ b/src/main/opencode/overlay-dir-gc.ts @@ -0,0 +1,206 @@ +import { lstat, readdir } from 'node:fs/promises' +import { join, resolve, sep } from 'node:path' +import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { isSafeDescendCandidate, safeRemoveOverlay } from '../pty/overlay-mirror' +import { + OPENCODE_OVERLAY_MANIFEST_FILE, + readOpenCodeOverlayManifest +} from './opencode-overlay-manifest' +import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership' + +export const OPENCODE_DIR_GC_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000 +export const OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP = 500 + +export type OpenCodeDirGcOptions = { + overlayRoot: string + pluginFileName: string + referencedConfigDirs: ReadonlySet + readLivePtyIds: () => Promise + now?: number + minAgeMs?: number + maxRemovals?: number + removeTree?: (directory: string, root: string) => void + yieldBetweenRemovals?: () => Promise +} + +export type OpenCodeDirGcResult = { + scanned: number + removed: number + failed: number + keptReferenced: number + keptYoung: number + keptSourcePresent: number + keptUnverifiable: number +} + +function normalized(path: string): string { + const absolute = resolve(path) + return process.platform === 'win32' ? absolute.toLowerCase() : absolute +} + +function isReferenced(directory: string, references: ReadonlySet): boolean { + const candidate = normalized(directory) + for (const reference of references) { + const value = normalized(reference) + if (value === candidate || value.startsWith(candidate + sep)) { + return true + } + } + return false +} + +async function oldEnough(directory: string, plugin: string, now: number, minAge: number) { + let newest = 0 + for (const path of [ + directory, + join(directory, OPENCODE_OVERLAY_MANIFEST_FILE), + join(directory, 'plugins'), + join(directory, 'plugins', plugin) + ]) { + try { + const stats = await lstat(path) + if (stats.isSymbolicLink()) { + return false + } + newest = Math.max(newest, stats.mtimeMs) + } catch { + return false + } + } + return newest > 0 && now - newest >= minAge +} + +async function hasNoServiceConfig(directory: string): Promise { + try { + const names = await readdir(directory) + // Service registration lives in a different profile; config absence proves no PID verdict. + return !names.some((name) => /^service(?:-[\w.-]+)?\.json$/i.test(name)) + } catch { + return false + } +} + +export async function sweepOrphanedOpenCodeDirs( + options: OpenCodeDirGcOptions +): Promise { + const result: OpenCodeDirGcResult = { + scanned: 0, + removed: 0, + failed: 0, + keptReferenced: 0, + keptYoung: 0, + keptSourcePresent: 0, + keptUnverifiable: 0 + } + if ((await inspectSourceDirectory(options.overlayRoot)) !== 'present') { + return result + } + const now = options.now ?? Date.now() + const minAge = options.minAgeMs ?? OPENCODE_DIR_GC_MIN_AGE_MS + const requestedLimit = options.maxRemovals ?? OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP + const limit = Number.isFinite(requestedLimit) + ? Math.min(OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP, Math.max(0, Math.floor(requestedLimit))) + : OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP + const removeTree = options.removeTree ?? safeRemoveOverlay + const yieldBetween = options.yieldBetweenRemovals ?? yieldToEventLoop + let entries + try { + entries = await readdir(options.overlayRoot, { withFileTypes: true }) + } catch { + return result + } + for (const entry of entries) { + if (result.removed + result.failed >= limit) { + break + } + result.scanned += 1 + const directory = join(options.overlayRoot, entry.name) + if (!/^[0-9a-f]{32}$/.test(entry.name) || !isSafeDescendCandidate(entry)) { + result.keptUnverifiable += 1 + continue + } + if (isReferenced(directory, options.referencedConfigDirs)) { + result.keptReferenced += 1 + continue + } + try { + const manifestStats = await lstat(join(directory, OPENCODE_OVERLAY_MANIFEST_FILE)) + if ( + !manifestStats.isFile() || + manifestStats.isSymbolicLink() || + manifestStats.size > 64 * 1024 + ) { + result.keptUnverifiable += 1 + continue + } + const source = await resolveOwnedOverlaySource( + directory, + readOpenCodeOverlayManifest(directory) + ) + if (!source) { + result.keptUnverifiable += 1 + continue + } + const presence = await inspectSourceDirectory(source) + if (presence === 'present') { + result.keptSourcePresent += 1 + continue + } + if (presence !== 'absent' || !(await hasNoServiceConfig(directory))) { + result.keptUnverifiable += 1 + continue + } + if (!(await oldEnough(directory, options.pluginFileName, now, minAge))) { + result.keptYoung += 1 + continue + } + // A restarted app's empty cache cannot establish surviving daemon terminals are gone. + const liveIds = await options.readLivePtyIds().catch(() => null) + if (liveIds === null || liveIds.length !== 0) { + result.keptUnverifiable += 1 + continue + } + if ( + isReferenced(directory, options.referencedConfigDirs) || + isReferenced(source, options.referencedConfigDirs) + ) { + result.keptReferenced += 1 + continue + } + if ( + (await inspectSourceDirectory(source)) !== 'absent' || + (await inspectSourceDirectory(options.overlayRoot)) !== 'present' || + !(await hasNoServiceConfig(directory)) + ) { + result.keptUnverifiable += 1 + continue + } + if ( + isReferenced(directory, options.referencedConfigDirs) || + isReferenced(source, options.referencedConfigDirs) + ) { + result.keptReferenced += 1 + continue + } + try { + removeTree(directory, options.overlayRoot) + try { + await lstat(directory) + result.failed += 1 + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + result.removed += 1 + } else { + result.failed += 1 + } + } + } catch { + result.failed += 1 + } + await yieldBetween() + } catch { + result.keptUnverifiable += 1 + } + } + return result +} diff --git a/src/main/opencode/overlay-dir-names.ts b/src/main/opencode/overlay-dir-names.ts new file mode 100644 index 00000000000..1e3a3a9a9f0 --- /dev/null +++ b/src/main/opencode/overlay-dir-names.ts @@ -0,0 +1,12 @@ +import { createHash } from 'node:crypto' + +export const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' +export const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' + +export function toSafeDirName(id: string): string { + return createHash('sha256').update(id).digest('hex').slice(0, 32) +} + +export function sourceOverlayDirName(sourceConfigDir: string): string { + return toSafeDirName(`source:${sourceConfigDir}`) +} diff --git a/src/main/opencode/overlay-source-ownership.test.ts b/src/main/opencode/overlay-source-ownership.test.ts new file mode 100644 index 00000000000..78c5d1c02ce --- /dev/null +++ b/src/main/opencode/overlay-source-ownership.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import * as filesystem from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership' +import { sourceOverlayDirName } from './overlay-dir-names' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, lstat: vi.fn(actual.lstat) } +}) + +let root: string +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-overlay-source-'))) +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +it('distinguishes a missing source from an inaccessible source and a linked ancestor', async () => { + const source = join(root, 'source') + mkdirSync(source) + expect(await inspectSourceDirectory(source)).toBe('present') + expect(await inspectSourceDirectory(join(root, 'missing', 'config'))).toBe('absent') + expect(await inspectSourceDirectory('relative-config')).toBe('unverifiable') + writeFileSync(join(root, 'file'), '') + expect(await inspectSourceDirectory(join(root, 'file'))).toBe('unverifiable') + symlinkSync(source, join(root, 'link'), process.platform === 'win32' ? 'junction' : 'dir') + expect(await inspectSourceDirectory(join(root, 'link', 'missing'))).toBe('unverifiable') + const error = Object.assign(new Error('Denied'), { code: 'EACCES' }) + vi.mocked(filesystem.lstat).mockRejectedValueOnce(error) + expect(await inspectSourceDirectory(source)).toBe('unverifiable') +}) + +it('requires an absolute source whose hash matches the owned overlay', async () => { + const source = join(root, 'source') + const overlay = join(root, sourceOverlayDirName(source)) + const manifest = { topLevelEntries: [], pluginEntries: [], sourceConfigDir: source } + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBe(source) + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, sourceConfigDir: join(root, 'other') }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, sourceConfigDir: 'relative' }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(join(root, '123'), { + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: source + }) + ).toBeUndefined() +}) + +it.skipIf(process.platform === 'win32')( + 'recognizes an older source-scoped manifest only through its named mirrored link', + async () => { + const source = join(root, 'retired-source') + const overlay = join(root, sourceOverlayDirName(source)) + mkdirSync(overlay) + symlinkSync(join(source, 'opencode.json'), join(overlay, 'opencode.json')) + const manifest = { topLevelEntries: ['opencode.json'], pluginEntries: [] } + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBe(source) + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, topLevelEntries: ['../other'] }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, topLevelEntries: [] }) + ).toBeUndefined() + rmSync(join(overlay, 'opencode.json')) + writeFileSync(join(overlay, 'opencode.json'), '{}') + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBeUndefined() + } +) + +it.skipIf(process.platform === 'win32')('rejects arbitrary legacy source links', async () => { + const source = join(root, 'source') + const overlay = join(root, sourceOverlayDirName(source)) + mkdirSync(overlay) + symlinkSync(join(root, 'other', 'opencode.json'), join(overlay, 'opencode.json')) + expect( + await resolveOwnedOverlaySource(overlay, { + topLevelEntries: ['opencode.json'], + pluginEntries: [] + }) + ).toBeUndefined() +}) diff --git a/src/main/opencode/overlay-source-ownership.ts b/src/main/opencode/overlay-source-ownership.ts new file mode 100644 index 00000000000..4f71cd191da --- /dev/null +++ b/src/main/opencode/overlay-source-ownership.ts @@ -0,0 +1,63 @@ +import { lstat, readlink } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path' +import { isSafeDescendCandidate } from '../pty/overlay-mirror' +import type { OpenCodeOverlayManifest } from './opencode-overlay-manifest' +import { sourceOverlayDirName } from './overlay-dir-names' + +export type SourceDirectoryPresence = 'present' | 'absent' | 'unverifiable' + +function isAbsent(error: unknown): boolean { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' +} + +export async function inspectSourceDirectory(path: string): Promise { + if (!isAbsolute(path)) { + return 'unverifiable' + } + const absolute = resolve(path) + let current = parse(absolute).root + const segments = relative(current, absolute).split(sep).filter(Boolean) + for (const segment of ['', ...segments]) { + current = join(current, segment) + try { + if (!isSafeDescendCandidate(await lstat(current))) { + return 'unverifiable' + } + } catch (error) { + return isAbsent(error) ? 'absent' : 'unverifiable' + } + } + return 'present' +} + +export async function resolveOwnedOverlaySource( + directory: string, + manifest: OpenCodeOverlayManifest +): Promise { + const matches = (source: string): boolean => + isAbsolute(source) && sourceOverlayDirName(source) === basename(directory) + if (manifest.sourceConfigDir !== undefined) { + return matches(manifest.sourceConfigDir) ? manifest.sourceConfigDir : undefined + } + // Older manifests prove a source only through a named, source-hash-matching mirror. + let source: string | undefined + for (const entry of manifest.topLevelEntries) { + if (!entry || basename(entry) !== entry || entry === '.' || entry === '..') { + return undefined + } + try { + const target = await readlink(join(directory, entry)) + const candidate = dirname(target) + if (!isAbsolute(target) || basename(target) !== entry || !matches(candidate)) { + return undefined + } + if (source !== undefined && source !== candidate) { + return undefined + } + source = candidate + } catch { + return undefined + } + } + return source +} diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 43f063dde81..fd6c39bf510 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -289,11 +289,8 @@ export function getStatusPluginFactorySource(options: { '};', ...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []), '', - '// Why: OpenCode also resolves plugins through the module default export, and that', - '// loader rejects the module unless the default exposes `server()` ("must default', - '// export an object with server()"). `setup()` does not satisfy it. Keep the named', - '// export so the factory-based loader still finds the same instance.', - 'export default {', + '// OpenCode 1 requires a callable default; OpenCode 2 validates a plugin object.', + 'export default process.env.ORCA_OPENCODE_PLUGIN_API === "v1" ? OrcaOpenCodeStatusPlugin : {', ` id: "${pluginID}",`, ' server: OrcaOpenCodeStatusPlugin,', ...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []), diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index ad9f51e7ad6..a71307a8df5 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -151,6 +151,7 @@ async function startOrcadRuntime( | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} + let removeStatusHookSettingsListener = (): void => {} registerCleanup(async () => { try { await rpc?.stop() @@ -167,6 +168,7 @@ async function startOrcadRuntime( // orcad restart goes back to killing every running terminal. await stopOrcadDaemon() } finally { + removeStatusHookSettingsListener() uninstallObservedStatusIdentity() uninstallHookStatusRepublish() agentHookServer.stop() @@ -194,9 +196,17 @@ async function startOrcadRuntime( uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(profileStore.getSettings())) { - await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) - } + await agentHookServer.start({ + env: 'production', + userDataPath: runtimeUserDataPath, + statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) + }) + + removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 530d06f4ee8..41bba43d4cf 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -1,7 +1,8 @@ import { mkdtempSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' @@ -14,6 +15,9 @@ const state = vi.hoisted(() => ({ controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, + onSettingsChanged: vi.fn(), + removeSettingsListener: vi.fn(), + startDaemon: vi.fn(async () => {}), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) @@ -26,7 +30,7 @@ vi.mock('./orcad-app-paths', () => ({ vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ - startOrcadDaemon: async () => {}, + startOrcadDaemon: state.startDaemon, stopOrcadDaemon: async () => {} })) vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) @@ -44,6 +48,7 @@ vi.mock('./orcad-profile-state-startup', () => ({ createOrcadProfileStateStartup: async () => ({ store: { getSettings: () => ({}), + onSettingsChanged: state.onSettingsChanged, flushFinalOrThrowAsync: async () => {}, freezeWritesAsync: async () => {} }, @@ -124,6 +129,10 @@ vi.mock('../runtime/push/push-gateway-client', () => ({ } })) +beforeEach(() => { + state.onSettingsChanged.mockReturnValue(state.removeSettingsListener) +}) + afterEach(() => { rmSync(state.root, { recursive: true, force: true }) vi.clearAllMocks() @@ -176,6 +185,11 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() expect(state.controller.getListenerCount()).toBe(0) + expect(state.rpcStarted).toBe(false) + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + await host.stop() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() expect(await state.controller.registerPushDevice({} as never)).toMatchObject({ registered: false }) @@ -189,3 +203,14 @@ it('releases admission when host setup fails before a runtime exists', async () expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) + +it('unsubscribes settings when daemon startup fails after hook setup', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-daemon-failure-')) + state.startDaemon.mockRejectedValueOnce(new Error('daemon setup failed')) + const { startOrcad } = await import('./orcad-entry') + await expect(startOrcad()).rejects.toThrow('daemon setup failed') + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() +}) diff --git a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts index 5f679d3128d..eff2cb3e6a5 100644 --- a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -127,14 +127,14 @@ describe('Store with an injected SQLite profile-state authority', () => { store.updateSettings({ terminalFontSize: store.getSettings().terminalFontSize + 1 }) await store.flushPendingOrThrowAsync() - expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(readFileSync(dataFile).equals(legacyBytes)).toBe(true) expect(existsSync(databaseFile)).toBe(true) const reloaded = new Store({ dataFile, profileStateAuthority: authority }) expect(reloaded.getSettings().theme).toBe('dark') expect(reloaded.getSettings().terminalFontSize).toBe(store.getSettings().terminalFontSize) expect(reloaded.getSettings().opencodeSessionCookie).toBe('authority-secret') - expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(readFileSync(dataFile).equals(legacyBytes)).toBe(true) reloaded.freezeWrites() }) @@ -848,7 +848,7 @@ describe('Store with an injected SQLite profile-state authority', () => { 'store-recovery-test' ) - expect(readFileSync(join(result.directory, 'profile-state.db'))).toEqual(sourceBytes) + expect(readFileSync(join(result.directory, 'profile-state.db')).equals(sourceBytes)).toBe(true) expect(readFileSync(join(result.directory, 'profile-state.db-wal'), 'utf8')).toBe( 'wal-preservation-sentinel' ) @@ -856,7 +856,7 @@ describe('Store with an injected SQLite profile-state authority', () => { profileId: 'profile-authority-test', reason: 'store-recovery-test' }) - expect(readFileSync(databasePath)).toEqual(sourceBytes) + expect(readFileSync(databasePath).equals(sourceBytes)).toBe(true) }) it('prepares frozen JSON imports without permitting file publication', () => { diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts index aa60618cc42..f712bc691ce 100644 --- a/src/main/persistence/loading-store/profile-state-store-backups.test.ts +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -129,7 +129,7 @@ describe('Store automatic SQLite recovery snapshots', () => { expect(readSnapshot(backups[0].path).state.workspaceSession.activeWorktreeId).toBe( 'backup-worktree' ) - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) expect( readdirSync(state.directory) @@ -208,7 +208,7 @@ describe('Store automatic SQLite recovery snapshots', () => { const backups = profileStateDatabaseBackups(state.databasePath) expect(backups).toHaveLength(2) expect(readSnapshot(backups[0].path).state.settings.theme).toBe('dark') - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(JSON.parse(readFileSync(state.dataFile, 'utf8'))).toEqual( readSnapshot(state.databasePath).state ) @@ -241,7 +241,7 @@ describe('Store automatic SQLite recovery snapshots', () => { ) expect(readSnapshot(state.databasePath).state.settings.theme).toBe('dark') expect(profileStateDatabaseBackups(state.databasePath)).toEqual(state.retained) - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(readSnapshot(state.retained[0].path).state.settings.theme).toBe('light') expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) expect(existsSync(`${state.dataFile}.bak.0`)).toBe(false) diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts index 8d7b7f72674..21c13c3808f 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts @@ -197,7 +197,7 @@ describe('profile state authority bootstrap', () => { expect(() => bootstrapProfileStateAuthority(options)).toThrow( ProfileStateRecoveryRequiredError ) - expect(readFileSync(options.databaseFile)).toEqual(before) + expect(readFileSync(options.databaseFile).equals(before)).toBe(true) } const exportPath = profileStateJsonExportPath(options.dataFile, 1) writeFileSync(exportPath, raw) @@ -502,7 +502,7 @@ describe('profile state authority bootstrap', () => { expect.arrayContaining([options.databaseFile, `${options.databaseFile}-wal`]) ) expect(existsSync(options.databaseFile)).toBe(false) - expect(readFileSync(options.dataFile)).toEqual(restoredJson) + expect(readFileSync(options.dataFile).equals(restoredJson)).toBe(true) expect(existsSync(exportPath)).toBe(false) expect(readFileSync(join(result.quarantine.directory, 'profile-state.db'), 'utf8')).toBe( 'corrupt sqlite primary' @@ -538,7 +538,7 @@ describe('profile state authority bootstrap', () => { exportPath }) ).toThrow('Profile state JSON is invalid') - expect(readFileSync(options.databaseFile)).toEqual(databaseBytes) - expect(readFileSync(options.dataFile)).toEqual(dataBytes) + expect(readFileSync(options.databaseFile).equals(databaseBytes)).toBe(true) + expect(readFileSync(options.dataFile).equals(dataBytes)).toBe(true) }) }) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts index 9e1d09b761f..a2d40b8328c 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -161,7 +161,7 @@ describe('profile state backup worker', () => { await expect(runProfileStateBackupWorker(job, { workerPath })).rejects.toThrow(expectedError) expect(existsSync(job.targetPath)).toBe(false) expect(readFileSync(retained, 'utf8')).toBe('previous recovery point') - expect(readFileSync(job.databasePath)).toEqual(before) + expect(readFileSync(job.databasePath).equals(before)).toBe(true) }) it.each(['true', 'false'])('waits for actual exit after an ok=%s response', async (ok) => { @@ -251,7 +251,7 @@ describe('profile state backup worker', () => { await expect( runProfileStateBackupWorker(job, { workerPath: join(directory, 'missing.js') }) ).rejects.toThrow() - expect(readFileSync(job.databasePath)).toEqual(before) + expect(readFileSync(job.databasePath).equals(before)).toBe(true) }) it('coalesces desktop work and drains a started backup before allowing quarantine', async () => { diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts index da0ebcf7ac5..238e067e9a3 100644 --- a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -80,7 +80,7 @@ describe('asynchronous profile-state database snapshots', () => { } expect(exportProfileStateJson(db)).toBe(originalJson) expect(db.pragma('journal_mode', { simple: true })).toBe('wal') - expect(readFileSync(databasePath)).toEqual(sourceFile) + expect(readFileSync(databasePath).equals(sourceFile)).toBe(true) importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) expect(readSnapshot(targetPath)).toBe(originalJson) expectNoTemporaryFiles(directory) @@ -150,7 +150,7 @@ describe('asynchronous profile-state database snapshots', () => { 'injected native backup failure' ) - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expect(exportProfileStateJson(db)).toBe(originalJson) expectNoTemporaryFiles(directory) }) @@ -184,7 +184,7 @@ describe('asynchronous profile-state database snapshots', () => { 'injected rename failure' ) - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expectNoTemporaryFiles(directory) }) @@ -202,7 +202,7 @@ describe('asynchronous profile-state database snapshots', () => { db.exec('ROLLBACK') } - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expect(exportProfileStateJson(db)).toBe(originalJson) expectNoTemporaryFiles(directory) }) diff --git a/src/main/persistence/profile-state/profile-state-database.test.ts b/src/main/persistence/profile-state/profile-state-database.test.ts index 245806b8ad0..b96858346d5 100644 --- a/src/main/persistence/profile-state/profile-state-database.test.ts +++ b/src/main/persistence/profile-state/profile-state-database.test.ts @@ -162,7 +162,7 @@ describe('profile state database', () => { } const after = statSync(dbPath) expect(after.size).toBe(before.size) - expect(readFileSync(dbPath)).toEqual(beforeBytes) + expect(readFileSync(dbPath).equals(beforeBytes)).toBe(true) }) it('opens the current schema read-only without changing its bytes', () => { @@ -179,7 +179,7 @@ describe('profile state database', () => { } finally { opened.db.close() } - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects a database whose profile identity does not match', () => { @@ -192,7 +192,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-b')).toThrowError( expect.objectContaining({ code: 'identity-mismatch' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects malformed database bytes without replacing them', () => { @@ -204,7 +204,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(bytes) + expect(readFileSync(dbPath).equals(bytes)).toBe(true) }) it('quarantines the database family without touching live recovery sources', () => { @@ -254,7 +254,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects an incomplete current schema without mutating it', () => { @@ -268,7 +268,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects current-version tables with incompatible columns without mutating them', () => { @@ -290,7 +290,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('does not create an empty database when the parent directory is absent', () => { diff --git a/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts index 75e498b8d70..857e4f1c7ef 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts @@ -1,4 +1,5 @@ import { + cpSync, existsSync, mkdirSync, mkdtempSync, @@ -8,7 +9,7 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, join, relative } from 'node:path' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { setSecretStore } from '../../../shared/secret-store' import { profileStateStorage } from '../../orca-profiles/profile-project-state-file' @@ -103,13 +104,25 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) } }) -afterAll(() => rmSync(suiteRoot, { recursive: true, force: true })) +afterAll(() => { + try { + for (const seed of seededFixtures.values()) { + for (const [suffix, bytes] of seed.originalFamily) { + expect(readFileSync(`${seed.databasePath}${suffix}`).equals(bytes)).toBe(true) + } + expect(readFileSync(seed.backupPath).equals(seed.backupBytes)).toBe(true) + expect(readFileSync(seed.exportPath, 'utf8')).toBe(selectedJson) + } + } finally { + rmSync(suiteRoot, { recursive: true, force: true }) + } +}) type Fixture = RecoveryCrashOptions & { backupBytes: Buffer; originalFamily: Map } +const seededFixtures = new Map() -async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise { - const root = mkdtempSync(join(suiteRoot, 'profile-')) - fixtureRoots.push(root) +async function seedFixture(kind: 'json' | 'sqlite', accepted: boolean): Promise { + const root = mkdtempSync(join(suiteRoot, 'seed-')) const directory = join(root, 'profiles', profileId) mkdirSync(directory, { recursive: true }) writeFileSync( @@ -173,6 +186,39 @@ async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise [ + suffix, + readFileSync(`${options.databasePath}${suffix}`) + ]) + ) + return { ...options, backupBytes: readFileSync(options.backupPath), originalFamily } +} + +async function fixture(kind: 'json' | 'sqlite', accepted: boolean): Promise { + const key = `${kind}/${accepted}` + let seed = seededFixtures.get(key) + if (!seed) { + // The seed child's close event has fired before its WAL family is copied. + seed = await seedFixture(kind, accepted) + seededFixtures.set(key, seed) + } + return cloneFixture(seed) +} + function readSqlite(path: string): unknown { const opened = openProfileStateDatabaseReadOnly(path, profileId) try { @@ -192,10 +238,12 @@ function assertQuarantine(profile: Fixture): void { const directory = join(dirname(profile.databasePath), quarantine) // Check exact family bytes before opening the copied WAL snapshot. for (const [suffix, bytes] of profile.originalFamily) { - expect(readFileSync(join(directory, `profile-state.db${suffix}`))).toEqual(bytes) + expect(readFileSync(join(directory, `profile-state.db${suffix}`)).equals(bytes)).toBe(true) } expect(readFileSync(join(directory, basename(profile.exportPath)), 'utf8')).toBe(selectedJson) - expect(readFileSync(join(directory, basename(profile.backupPath)))).toEqual(profile.backupBytes) + expect( + readFileSync(join(directory, basename(profile.backupPath))).equals(profile.backupBytes) + ).toBe(true) expect(readSqlite(join(directory, 'profile-state.db'))).toEqual(oldState) } @@ -346,7 +394,7 @@ describe('SQLite recovery process death', () => { const profile = await fixture('sqlite', true) await killRecoveryAt(bundle, profile, stage(profile, boundary)) assertQuarantine(profile) - expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes) + expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true) const expected = [ 'marker-invalidated', 'selected-export', @@ -365,7 +413,7 @@ describe('SQLite recovery process death', () => { : 'refused' assertRestart(profile, expected) retry(profile) - expect(readFileSync(profile.backupPath)).toEqual(profile.backupBytes) + expect(readFileSync(profile.backupPath).equals(profile.backupBytes)).toBe(true) }) it.skipIf(process.platform === 'win32')( @@ -378,3 +426,38 @@ describe('SQLite recovery process death', () => { } ) }) + +describe('seeded recovery fixture copies', () => { + it.each([ + ['json', false], + ['json', true], + ['sqlite', true] + ] as const)( + 'isolates %s/accepted=%s through corruption and WAL reopen', + async (kind, accepted) => { + const first = await fixture(kind, accepted) + const second = await fixture(kind, accepted) + const seed = seededFixtures.get(`${kind}/${accepted}`) + if (!seed) { + throw new Error('Seed fixture was not retained') + } + expect(new Set([first.root, second.root, seed.root]).size).toBe(3) + for (const [suffix, bytes] of seed.originalFamily) { + expect(readFileSync(`${first.databasePath}${suffix}`).equals(bytes)).toBe(true) + writeFileSync(`${first.databasePath}${suffix}`, 'corrupted-copy') + expect(readFileSync(`${second.databasePath}${suffix}`).equals(bytes)).toBe(true) + expect(readFileSync(`${seed.databasePath}${suffix}`).equals(bytes)).toBe(true) + } + const third = await fixture(kind, accepted) + expect(readSqlite(third.databasePath)).toEqual(oldState) + expect(readFileSync(seed.backupPath).equals(seed.backupBytes)).toBe(true) + expect(readFileSync(seed.exportPath, 'utf8')).toBe(selectedJson) + } + ) + + it('rejects an incomplete copied WAL family before a recovery child starts', async () => { + const incomplete = await fixture('json', false) + rmSync(`${incomplete.databasePath}-wal`) + expect(() => cloneFixture(incomplete)).toThrow(/ENOENT/) + }) +}) diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index 5abac7cd342..c06f9ee7f12 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -31,6 +31,7 @@ import { hydrateShellPathForAgentDetection } from '../ipc/agent-detection-shell- import { execCommandInWslOrThrow, execLocalPreflightCommandOrThrow, + findRunnableLocalCommand, isCommandAvailable, isCommandOnPath, shellQuote @@ -126,20 +127,24 @@ function uniqueAgentIds(ids: Iterable): string[] { return [...new Set(ids)] } +/** A CLI verdict and, on the local path, the exact copy that produced it. */ +type CommandRuntime = { installed: boolean; wslTarget?: WslPreflightTarget; binary?: string } + async function detectCommandRuntime( command: string, context?: PreflightRuntimeContext -): Promise<{ installed: boolean; wslTarget?: WslPreflightTarget }> { +): Promise { const wslTarget = getPreflightWslTarget(context) if (wslTarget) { return (await isCommandAvailable(command, wslTarget)) ? { installed: true, wslTarget } : { installed: false } } - if (await isCommandAvailable(command)) { - return { installed: true } - } - return { installed: false } + // Pin auth to the copy that passed --version, so PATH cannot select the dead shim again. + const probe = await findRunnableLocalCommand(command) + return probe.status === 'available' + ? { installed: true, binary: probe.binary } + : { installed: false } } export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { @@ -253,11 +258,15 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis return uniqueAgentIds(result.agents) } -async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise { +// Why the probe object rather than the bare command name: on the local path +// `binary` is the copy that just passed `--version`, which on a shim-shadowed +// host is not what PATH would resolve (#22975). WSL has no `binary` — the guest +// resolves the name inside the distro, where Orca's PATH ordering cannot apply. +async function isGhAuthenticated(probe: CommandRuntime): Promise { try { - await (wslTarget - ? execCommandInWslOrThrow(wslTarget, `${shellQuote('gh')} auth status`) - : execLocalPreflightCommandOrThrow('gh', ['auth', 'status'])) + await (probe.wslTarget + ? execCommandInWslOrThrow(probe.wslTarget, `${shellQuote('gh')} auth status`) + : execLocalPreflightCommandOrThrow(probe.binary ?? 'gh', ['auth', 'status'])) // Why: for plain-text `gh auth status`, exit 0 means gh did not detect any // authentication issues for the checked hosts/accounts. return true @@ -274,11 +283,11 @@ async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise { +async function isGlabAuthenticated(probe: CommandRuntime): Promise { try { - await (wslTarget - ? execCommandInWslOrThrow(wslTarget, `${shellQuote('glab')} auth status`) - : execLocalPreflightCommandOrThrow('glab', ['auth', 'status'])) + await (probe.wslTarget + ? execCommandInWslOrThrow(probe.wslTarget, `${shellQuote('glab')} auth status`) + : execLocalPreflightCommandOrThrow(probe.binary ?? 'glab', ['auth', 'status'])) return true } catch (error) { const stdout = (error as { stdout?: string }).stdout ?? '' @@ -380,8 +389,8 @@ async function executePreflightCheck( ]) const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([ - ghProbe.installed ? isGhAuthenticated(ghProbe.wslTarget) : Promise.resolve(false), - glabProbe.installed ? isGlabAuthenticated(glabProbe.wslTarget) : Promise.resolve(false), + ghProbe.installed ? isGhAuthenticated(ghProbe) : Promise.resolve(false), + glabProbe.installed ? isGlabAuthenticated(glabProbe) : Promise.resolve(false), getBitbucketAuthStatus(), getAzureDevOpsAuthStatus(), getGiteaAuthStatus() diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index d16715ed496..169773edbd5 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -5,6 +5,7 @@ * startup-file chain, OSC 133 hooks, and the shell-ready marker all live here. */ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' @@ -47,6 +48,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${ORCA_CLI_POSIX_PATH_RESTORE} ${WSL_MANAGED_CLI_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 7ac956b70fd..18ac3b4683e 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -1,3 +1,4 @@ +import { openCodeHookServiceModuleMock } from '../ipc/pty-ipc-mock-registry' import { settledWriteStub } from './settled-pty-write-stub' import { describe, expect, it, vi } from 'vitest' import { setPtyHostBindings } from '../ipc/pty-host-bindings' @@ -48,18 +49,7 @@ vi.mock('node-pty', () => ({ }) })) -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - }, - openCode2HookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - } -})) +vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock()) vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } @@ -175,6 +165,12 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID', + 'ORCA_OPENCODE_PLUGIN_API', + 'ORCA_OPENCODE_STARTUP_PROMPT_BODY', + 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT', + 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE', + 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256', + 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', 'ORCA_PI_TITLE_MARKER_OWNED', diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 2886a00e8b6..4eb5dc45a60 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -4,8 +4,11 @@ import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session- import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { TerminalOwner } from '../../shared/terminal-owner' +import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' export type PtySpawnResult = { + /** Execution-host probe; absent on older hosts and reattachments. */ + openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/main/providers/ssh-pty-provider-spawn.test.ts b/src/main/providers/ssh-pty-provider-spawn.test.ts index 06eb32d586f..6626a238ce0 100644 --- a/src/main/providers/ssh-pty-provider-spawn.test.ts +++ b/src/main/providers/ssh-pty-provider-spawn.test.ts @@ -460,6 +460,7 @@ describe('spawn', () => { PATH: '/home/user/.orca-relay/bin:/usr/bin', ORCA_TERMINAL_HANDLE: 'term_ssh', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true', + ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', ORCA_RELAY_NODE_PATH: '/usr/bin/node', @@ -490,6 +491,7 @@ describe('spawn', () => { env: { ORCA_TERMINAL_HANDLE: 'term_ssh', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true', + ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', ORCA_RELAY_NODE_PATH: '/usr/bin/node', diff --git a/src/main/providers/ssh-pty-spawn-env.test.ts b/src/main/providers/ssh-pty-spawn-env.test.ts new file mode 100644 index 00000000000..b1fba08b0ca --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-env.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env' +import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract' + +const bridge: RemoteCliBridgeEnv = { + binDir: '/remote/orca/bin', + relayDir: '/remote/orca', + nodePath: '/remote/node', + sockPath: '/remote/orca/socket' +} + +describe('SSH CLI path ownership', () => { + it('replaces the client restore directory with the remote bridge', () => { + const env = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/client/orca/bin' } + const result = buildSshPtySpawnEnv({ env, remoteCliBridgeEnv: bridge }) + expect(result.ORCA_CLI_BIN_DIR).toBe(bridge.binDir) + expect(result.PATH).toBe(`${bridge.binDir}:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe('/client/orca/bin') + }) + + it('clears a client path when no remote bridge is available', () => { + const result = buildSshPtySpawnEnv({ env: { ORCA_CLI_BIN_DIR: '/client/orca/bin' } }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + }) + + it('does not give a POSIX wrapper a Windows bridge directory', () => { + const result = buildSshPtySpawnEnv({ + env: { Path: 'C:\\Windows', ORCA_CLI_BIN_DIR: '/client/orca/bin' }, + remoteCliBridgeEnv: { ...bridge, binDir: 'C:\\Orca\\bin', pathDelimiter: ';' } + }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + expect(result.Path).toBe('C:\\Orca\\bin;C:\\Windows') + }) + + it('preserves an explicitly deleted restore key', () => { + const result = buildSshPtySpawnEnv({ + env: { PATH: '/usr/bin' }, + remoteCliBridgeEnv: bridge, + envToDelete: ['ORCA_CLI_BIN_DIR'] + }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + }) +}) diff --git a/src/main/providers/ssh-pty-spawn-env.ts b/src/main/providers/ssh-pty-spawn-env.ts index 595e750c90d..45e2abca8bd 100644 --- a/src/main/providers/ssh-pty-spawn-env.ts +++ b/src/main/providers/ssh-pty-spawn-env.ts @@ -7,8 +7,13 @@ export function buildSshPtySpawnEnv(args: { remoteCliBridgeEnv?: RemoteCliBridgeEnv }): Record { const merged = { ...args.env } + // The client CLI path cannot be restored on the execution host. + delete merged.ORCA_CLI_BIN_DIR if (args.remoteCliBridgeEnv) { const pathDelimiter = args.remoteCliBridgeEnv.pathDelimiter ?? ':' + if (pathDelimiter === ':') { + merged.ORCA_CLI_BIN_DIR = args.remoteCliBridgeEnv.binDir + } const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null if (pathKey) { const pathValue = merged[pathKey] ?? '' diff --git a/src/main/pty/overlay-mirror.test.ts b/src/main/pty/overlay-mirror.test.ts index 5499d226422..1a741552719 100644 --- a/src/main/pty/overlay-mirror.test.ts +++ b/src/main/pty/overlay-mirror.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import type * as NodePath from 'node:path' @@ -19,6 +19,33 @@ afterEach(() => { }) describe('safeRemoveOverlay', () => { + it('keeps missing owned paths a silent no-op', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-missing-')) + tempRoots.push(root) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(root, 'missing', 'leaf'), join(root, 'missing')) + expect(warn).not.toHaveBeenCalled() + }) + + it('refuses cleanup through an intermediate symlink and unlinks only a leaf symlink', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-symlink-')) + tempRoots.push(root) + const overlay = join(root, 'overlay') + const outside = join(root, 'outside') + mkdirSync(overlay) + mkdirSync(outside) + const sentinel = join(outside, 'keep.txt') + writeFileSync(sentinel, 'private sentinel') + const linked = join(overlay, 'linked') + symlinkSync(outside, linked, process.platform === 'win32' ? 'junction' : 'dir') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(linked, 'keep.txt'), overlay) + expect(existsSync(sentinel)).toBe(true) + safeRemoveOverlay(linked, overlay) + expect(existsSync(linked)).toBe(false) + expect(existsSync(sentinel)).toBe(true) + }) + it('removes valid overlay children whose names start with dot-dot', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) tempRoots.push(root) diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index ec1b99d2b92..6a35844f871 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -138,6 +138,33 @@ export function safeRemoveTree(path: string): void { } } +// Why: cleanup must not traverse a symlink parent inside the owned overlay. +function hasSafeOverlayAncestors(root: string, relativeTarget: string): boolean { + let current = root + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + const segments = relativeTarget.split(sep).filter(Boolean) + for (const [index, segment] of segments.entries()) { + current = join(current, segment) + if (index === segments.length - 1) { + break + } + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + } + return true +} + // Why: last-line guard against an overlay-root constant ever being // mis-resolved. Any caller that points safeRemoveTree at a path outside its // designated overlay root is refused so a misconfiguration cannot turn into @@ -147,7 +174,13 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void const resolvedRoot = resolve(overlayRoot) const resolvedTarget = resolve(overlayDir) const rel = relative(resolvedRoot, resolvedTarget) - if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + if ( + rel === '' || + rel === '..' || + rel.startsWith(`..${sep}`) || + isAbsolute(rel) || + !hasSafeOverlayAncestors(resolvedRoot, rel) + ) { console.warn( `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` ) diff --git a/src/main/ripgrep/ripgrep-filename-identity-real.test.ts b/src/main/ripgrep/ripgrep-filename-identity-real.test.ts new file mode 100644 index 00000000000..6fc7116bd7d --- /dev/null +++ b/src/main/ripgrep/ripgrep-filename-identity-real.test.ts @@ -0,0 +1,62 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { spawnBundledRipgrep } from './bundled-ripgrep-spawn' +import { buildRgArgsForQuickOpen, normalizeQuickOpenRgLine } from '../../shared/quick-open-filter' +import { buildRgArgs, createAccumulator, ingestRgJsonLine } from '../../shared/text-search' +import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' + +async function capture(root: string, args: string[]): Promise { + const child = spawnBundledRipgrep(args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + child.stderr?.resume() + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => (code === 0 ? resolve() : reject(new Error(`rg exit ${code}`)))) + }) + return output +} + +describe.skipIf(process.platform === 'win32')('real ripgrep POSIX filename identities', () => { + it('lists, searches, and opens literal-backslash and nested names independently', async () => { + const parent = await mkdtemp(join(tmpdir(), 'orca-rg-identity-')) + const root = join(parent, 'repo\\root') + try { + await mkdir(join(root, 'a'), { recursive: true }) + await writeFile(join(root, 'a\\b.txt'), 'needle literal') + await writeFile(join(root, 'a/b.txt'), 'needle nested') + const args = buildRgArgsForQuickOpen({ + searchRoot: '.', + excludePathPrefixes: [], + forceSlashSeparator: false + }) + const listing = await capture(root, args.primary) + const names = listing + .split('\0') + .filter(Boolean) + .map((line) => normalizeQuickOpenRgLine(line, { kind: 'cwd-relative' })) + expect(names.sort()).toEqual(['a/b.txt', 'a\\b.txt'].sort()) + for (const name of names) { + expect(name).not.toBeNull() + if (name === null) { + throw new Error('invalid name') + } + expect(await readFile(joinWorktreeRelativePath(root, name), 'utf8')).toBe( + name === 'a\\b.txt' ? 'needle literal' : 'needle nested' + ) + } + const acc = createAccumulator() + for (const line of (await capture(root, buildRgArgs('needle', '.', {}))).split('\n')) { + ingestRgJsonLine(line, root, acc, 10) + } + expect([...acc.fileMap.values()].map((file) => file.relativePath).sort()).toEqual(names) + expect(acc.truncated).toBe(false) + } finally { + await rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ripgrep/text-search-unicode-real.test.ts b/src/main/ripgrep/text-search-unicode-real.test.ts new file mode 100644 index 00000000000..b56c1794a19 --- /dev/null +++ b/src/main/ripgrep/text-search-unicode-real.test.ts @@ -0,0 +1,123 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnBundledRipgrep } from './bundled-ripgrep-spawn' +import { + buildRgArgs, + createAccumulator, + finalize, + ingestRgJsonLine +} from '../../shared/text-search' + +describe('ripgrep Unicode match coordinates', () => { + let root: string + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-unicode-')) + }) + afterEach(async () => { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + }) + + async function search( + query: string, + useRegex = false, + includePattern?: string, + excludePattern?: string + ) { + const child = spawnBundledRipgrep( + buildRgArgs(query, '.', { useRegex, includePattern, excludePattern }), + { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + child.stderr?.resume() + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => + code === 0 || code === 1 ? resolve() : reject(new Error(`rg exit ${code}`)) + ) + }) + const acc = createAccumulator() + for (const line of output.split('\n')) { + ingestRgJsonLine(line, root, acc, 2000) + } + return finalize(acc) + } + + it('uses UTF16 columns and lengths for multibyte prefixes and astral matches', async () => { + await writeFile(join(root, 'example.txt'), '😀 café 日本 needle 😀 needle\r\n') + const result = await search('needle|😀', true) + expect( + result.files[0]?.matches.map(({ column, matchLength }) => [column, matchLength]) + ).toEqual([ + [1, 2], + [12, 6], + [19, 2], + [22, 6] + ]) + }) + + it.each(['src/**', '/src/**'])('honors root-relative include glob %s', async (includePattern) => { + await mkdir(join(root, 'src')) + await mkdir(join(root, 'other')) + await writeFile(join(root, 'src', 'match.txt'), 'needle') + await writeFile(join(root, 'src', 'skip.txt'), 'needle') + await writeFile(join(root, 'other', 'match.txt'), 'needle') + const result = await search('needle', false, includePattern, '/src/skip.txt') + expect(result.files.map((file) => [file.filePath, file.relativePath])).toEqual([ + [join(root, 'src', 'match.txt'), 'src/match.txt'] + ]) + }) + + it('keeps navigation and clamped display coordinates aligned', async () => { + const prefix = '日本😀'.repeat(200) + await writeFile(join(root, 'long.txt'), `${prefix}needle`) + const match = (await search('needle')).files[0]?.matches[0] + expect(match?.column).toBe(prefix.length + 1) + expect( + match?.lineContent.slice( + (match.displayColumn ?? 1) - 1, + (match.displayColumn ?? 1) - 1 + match.matchLength + ) + ).toBe('needle') + }) + + it('decodes malformed UTF8 context and maps matches using the original bytes', async () => { + const bytes = Buffer.concat([ + Buffer.from('😀 '), + Buffer.from([0xe2, 0x82, 0xff]), + Buffer.from(' needle é needle\r\n') + ]) + await writeFile(join(root, 'malformed.txt'), bytes) + const content = bytes.toString('utf8').replace(/\n$/, '') + const result = await search('needle') + expect(result.totalMatches).toBe(2) + expect( + result.files[0]?.matches.map((match) => [match.column, match.matchLength, match.lineContent]) + ).toEqual([ + [content.indexOf('needle') + 1, 6, content], + [content.lastIndexOf('needle') + 1, 6, content] + ]) + expect(result.truncated).toBe(false) + }) + + it('ignores external rg config while retaining workspace ignore files', async () => { + const config = join(root, 'config') + await writeFile(config, '--invert-match\n') + vi.stubEnv('RIPGREP_CONFIG_PATH', config) + await writeFile(join(root, '.ignore'), 'ignored.txt\n') + await writeFile(join(root, 'ignored.txt'), 'needle\n') + await mkdir(join(root, 'docs')) + await writeFile(join(root, 'docs', 'visible.txt'), 'needle\nother\n') + const result = await search('needle') + expect(result.files.map((file) => file.relativePath)).toEqual(['docs/visible.txt']) + expect(result.files[0]?.matches[0]?.lineContent).toBe('needle') + }) +}) diff --git a/src/main/runtime/__fixtures__/dsb-6-9-0-folder.meta.json b/src/main/runtime/__fixtures__/dsb-6-9-0-folder.meta.json new file mode 100644 index 00000000000..226fe306d57 --- /dev/null +++ b/src/main/runtime/__fixtures__/dsb-6-9-0-folder.meta.json @@ -0,0 +1,12 @@ +{ + "capturedAt": "2026-10-02T09:55:40.033Z", + "platform": "darwin", + "command": ["/tmp/orca-deepseek-build-20261002/bin/dsb"], + "cols": 110, + "rows": 32, + "note": "Third-party innocarpe DeepSeek Build 6.9.0 release; isolated HOME; folder workspace; synthetic credential; welcome screen, no prompt or model request", + "exitCode": 0, + "sourceRevision": "74df67a56988e9a32845c4565cc62b021ea68c7d", + "releaseSha256": "a57f225a537fc5c027ac4592e3f37f7bdc28cc2d6e27a366934511ea565cb874", + "sourceTag": "v6.9.0" +} diff --git a/src/main/runtime/__fixtures__/dsb-6-9-0-folder.txt b/src/main/runtime/__fixtures__/dsb-6-9-0-folder.txt new file mode 100644 index 00000000000..5b3aa4ca755 --- /dev/null +++ b/src/main/runtime/__fixtures__/dsb-6-9-0-folder.txt @@ -0,0 +1 @@ +]0;DeepSeek Build]0;DeepSeek Build[?1049h[?1000h[?1002h[?1003h[?1015h[?1006h[?1004h[?2004h[?25l[>0q[?2026h/p/t/orca-deepseek-build-20261002/workspace╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮│││⠀⠀⠀⣀⣠⣤⣤⣤⣤⣶⣶⠂⠀⠀⢠⣷⣀⠀⠀⠀⠀⣀DeepSeek Build 6.9.0││⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠘⣿⣿⣷⣶⣾⣿⡟DeepSeekBuild—Grok-classagent,DeepSeeknative.Feedback:/feedback││⣰⣿⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠈⢻⣿⡿⠟⠋││⣿⡇⠀⠀⠀⠉⠙⢿⣿⣿⣿⡯⡍⠻⣿⣿⣿⣿⠇New worktreectrl+w││⢿⣿⡀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣷⣄⣼⣿⣿⡟Resume sessionctrl+r││⠘⣿⣷⡀⠀⠀⠀⣀⠀⠀⠻⣿⣿⣿⣿⣿⡟⠁Changelog││⠀⠈⠻⣿⣦⣄⣀⣹⣿⣦⣀⠈⠻⣿⣿⣯⣄⡀Quitctrl+q││⠀⠀⠀⠈⠛⠿⢿⣿⣿⣿⣿⠿⠟⠉⠉⠉⠉⠁│││╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯╭────────────────────────────────────────────────────────────────────────────────────────────────────────╮│❯│╰──────────────────────────────────────────────────────────────────────────────DeepSeek V4 Flash (high)╯LoggedinwithAPIkey[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--dsb-6-9-0-folder@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--dsb-6-9-0-folder@unknown.json new file mode 100644 index 00000000000..896eb33614d --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--dsb-6-9-0-folder@unknown.json @@ -0,0 +1,7 @@ +{ + "description": "non-agent recording at 110x32 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": ["0-22: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start"], + "clockless": ["0-22: verdict=ready-weak wait=ready@start"] + } +} diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index ad7095abddc..91da0ed9dfe 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,11 +1,11 @@ export { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' -import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalAgent } from '../../shared/terminal-agent' export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS const AGENT_PROMPT_EFFECT_POLL_MS = 50 -const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['antigravity', 'codex', 'kimi']) +const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['antigravity', 'codex', 'kimi']) /** The prompt bytes are written before verification, so this only ever means "not observed". */ export const AGENT_PROMPT_STALLED_ERROR = 'agent_prompt_stalled' @@ -44,7 +44,7 @@ type AgentPromptVerificationOptions = { timeoutMs?: number } -export function resolveAgentPromptEffectTimeoutMs(agent: TuiAgent | null | undefined): number { +export function resolveAgentPromptEffectTimeoutMs(agent: TerminalAgent | null | undefined): number { return agent && HOOK_OBSERVED_TURN_START_AGENTS.has(agent) ? AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS : AGENT_PROMPT_EFFECT_TIMEOUT_MS @@ -52,7 +52,7 @@ export function resolveAgentPromptEffectTimeoutMs(agent: TuiAgent | null | undef /** Only these providers expose a turn-start signal Orca can settle a prompt receipt against. */ export function isTerminalSendSettlementAgent( - agent: TuiAgent | null | undefined + agent: TerminalAgent | null | undefined ): agent is 'antigravity' | 'claude' | 'codex' { return agent === 'antigravity' || agent === 'claude' || agent === 'codex' } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index cd87b024c0f..3ef04041a43 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -13,7 +13,6 @@ import { agentSessionOperationKey, type AgentSessionOperationClaim, type AgentSessionOperationDecision, - type AgentSessionOperationOutcome, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { @@ -68,9 +67,12 @@ import { import type { AgentSessionStoreState } from './agent-session-record-store-file' import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +type AgentSessionOperationSettlement = Parameters[1] + export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -296,14 +298,15 @@ export class AgentSessionRecordStore { }): Promise => this.transact((draft) => claimAgentSessionOperationInto(draft, args)) - async recordOperationOutcome(args: { - callerKey?: string - operationId: string - outcome: AgentSessionOperationOutcome - }): Promise { + async recordOperationOutcome(args: AgentSessionOperationSettlement): Promise { await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) } + /** The same settlement, committed by the journal write that makes it true. It changes only the + * ledger, so no record listener is owed. */ + operationOutcomeReceipt = (args: AgentSessionOperationSettlement): JournalOperationReceipt => + this.transactions.receipt((draft) => settleAgentSessionOperationInto(draft, args)) + replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts index 5ab6cad9e02..ab268c08db7 100644 --- a/src/main/runtime/agent-session-store-transactions.test.ts +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -140,6 +140,57 @@ describe('writing a transaction', () => { }) }) +describe('a receipt', () => { + const operationId = `${NOW}-${'7'.repeat(32)}` + const outcome = { status: 'succeeded' as const, sessionId: 'chat-a-0001' } + + async function pendingOperation(): Promise { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + await store.admitOperation({ callerKey: 'client-1', operationId, fingerprint: 'fp', now: NOW }) + return store + } + + async function persistedStatus(): Promise { + const reopened = await openTestAgentSessionRecordStore(root) + return reopened.getOperationRow('client-1', operationId)?.outcome.status + } + + // Written inside the caller's journal transaction, so it commits or rolls back with that write. + it('shows its rows in memory only once committed is called after the commit', async () => { + const store = await pendingOperation() + const receipt = store.operationOutcomeReceipt({ callerKey: 'client-1', operationId, outcome }) + + openTestJournalHostDatabase(root).transaction((db) => { + receipt.write(db) + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + }) + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + expect(await persistedStatus()).toBe('succeeded') + + receipt.committed() + expect(store.getOperationRow('client-1', operationId)?.outcome).toEqual(outcome) + }) + + it('leaves memory and rows as they were when the transaction rolls back', async () => { + const store = await pendingOperation() + const receipt = store.operationOutcomeReceipt({ callerKey: 'client-1', operationId, outcome }) + + expect(() => + openTestJournalHostDatabase(root).transaction((db) => { + receipt.write(db) + throw new Error('the journal row was refused') + }) + ).toThrow('the journal row was refused') + + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + expect(await persistedStatus()).toBe('pending') + // The next store transaction diffs from what committed, not from the discarded draft. + await store.setConversationName('chat-a-0001', 'after') + expect(await persistedStatus()).toBe('pending') + }) +}) + describe('a change a load would refuse', () => { it('rejects a tab id that could not prefix a pane key, and keeps memory and rows as they were', async () => { const store = await openTestAgentSessionRecordStore(root) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 879ff4a160e..31bddace873 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -8,6 +8,7 @@ // the async boundary every awaiting caller was written against. import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' import type { AgentSessionStoreState } from './agent-session-record-store-file' @@ -104,6 +105,31 @@ export class AgentSessionStoreTransactions { return run } + /** + * `apply`'s rows, written inside a journal transaction the caller runs and adopted once it + * commits. Exact without the queue: `write` and `committed` run in one synchronous step, so no + * store transaction can commit between the draft's staging and its adoption. + */ + receipt(apply: (draft: AgentSessionStoreState) => void): JournalOperationReceipt { + let staged: StagedStoreTransaction | null = null + return { + write: (db) => { + if (this.journalDatabase.readOnly) { + throw readOnlyStoreRefusal() + } + staged = this.stage(apply) + const writes = staged.writes + if (writes) { + writeAgentSessionStoreRows(db, writes) + } + }, + committed: () => { + staged?.adopt() + staged = null + } + } + } + private commit(apply: (draft: AgentSessionStoreState) => T, inMemoryWhenReadOnly: boolean): T { const readOnly = this.journalDatabase.readOnly if (readOnly && !inMemoryWhenReadOnly) { diff --git a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts index 1df9ccefdad..1735c102151 100644 --- a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID, + waitForTranscriptIdle +} from './agent-transcript-pane-test-harness' import { finalReplayFrame, readRuntimeFixture, @@ -112,9 +116,8 @@ describe('Antigravity 1.2.14 readiness from captured bytes', () => { data: `${String.fromCharCode(27)}]0;agy${String.fromCharCode(7)}${readRuntimeFixture('antigravity-1-2-14-model-picker')}`, size: { cols: 120, rows: 40 } }) - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - ).rejects.toThrow(/timeout/) + await runtime.readTerminal(handle, { screen: true }) + await expect(waitForTranscriptIdle({ runtime, handle }, 5_000)).rejects.toThrow(/timeout/) }, 15_000) // Why this recording: only the screen reads it ready, so settling proves the grid is trusted. @@ -133,11 +136,8 @@ describe('Antigravity 1.2.14 readiness from captured bytes', () => { runtime.onExternalPtyResize(TRANSCRIPT_PANE_PTY_ID, cols, rows) } const settles = async () => - ( - await runtime - .waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) - .catch(() => ({ satisfied: false })) - ).satisfied + (await waitForTranscriptIdle({ runtime, handle }, 5_000).catch(() => ({ satisfied: false }))) + .satisfied options.size = { cols: 120, rows: 40 } runtime.reflowHeadlessTerminalToPtyGrid(TRANSCRIPT_PANE_PTY_ID, 120, 40) await runtime.readTerminal(handle, { screen: true }) diff --git a/src/main/runtime/dsb-terminal-runtime.test.ts b/src/main/runtime/dsb-terminal-runtime.test.ts new file mode 100644 index 00000000000..644e0e1095d --- /dev/null +++ b/src/main/runtime/dsb-terminal-runtime.test.ts @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' +import type { RuntimeTerminalSummary } from '../../shared/runtime-terminal-contracts' +import type { TerminalAgent } from '../../shared/terminal-agent' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/dsb', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/dsb', + isBare: false, + isMainWorktree: false + } + ]) +})) + +describe('manually started DeepSeek Build terminals', () => { + afterEach(() => vi.useRealTimers()) + + it('publishes title-only identity from captured release PTY bytes', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the store subset used by terminal observations. + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setPtyController({ + spawn: async () => ({ id: 'pty-dsb' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const terminal = await runtime.createTerminal('path:/tmp/worktree-a') + const transcript = readFileSync(join(__dirname, '__fixtures__', 'dsb-6-9-0-folder.txt'), 'utf8') + expect(transcript).toContain('\x1b]0;DeepSeek Build\x07') + for (let offset = 0; offset < transcript.length; offset += 101) { + runtime.onPtyData('pty-dsb', transcript.slice(offset, offset + 101), Date.now()) + } + const listing = await runtime.listTerminals() + expect(listing.terminals).toEqual([ + expect.objectContaining({ handle: terminal.handle, agentIdentity: 'dsb' }) + ]) + }) + + it.each(['✦', '⏲', '◇', '✋'])( + 'preserves Build title and identity through OSC normalization with task glyph %s', + async (glyph) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the store subset used by terminal observations. + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setPtyController({ + spawn: async () => ({ id: 'pty-dsb' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const terminal = await runtime.createTerminal('path:/tmp/worktree-a') + const title = `⠋ - Review ${glyph} rendering - DeepSeek Build` + runtime.onPtyData('pty-dsb', `\x1b]0;${title}\x07`, Date.now()) + const listing = await runtime.listTerminals() + expect(listing.terminals).toEqual([ + expect.objectContaining({ handle: terminal.handle, agentIdentity: 'dsb', title }) + ]) + } + ) + + it('publishes observed DSB identity and sends a prompt with generic input behavior', async () => { + vi.useFakeTimers() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the store subset exercised by terminal recognition and prompt submission. + const runtime = new OrcaRuntimeService(makeStore() as never) + const writes: string[] = [] + runtime.setPtyController({ + spawn: async () => ({ id: 'pty-dsb' }), + write: (_ptyId, data) => { + writes.push(data) + if (data === '\r') { + runtime.onPtyData('pty-dsb', '\x1b]0;⠋ - Review Codex - DeepSeek Build\x07', Date.now()) + } + return true + }, + kill: () => true, + getForegroundProcess: async () => 'dsb' + }) + const terminal = await runtime.createTerminal('path:/tmp/worktree-a') + runtime.onPtyData('pty-dsb', '\x1b]0;DeepSeek Build\x07', Date.now()) + await runtime.refreshPtyForegroundAgentFromController('pty-dsb') + const listing = await runtime.listTerminals() + expectTypeOf().toEqualTypeOf< + TerminalAgent | undefined + >() + expect(listing.terminals).toEqual([ + expect.objectContaining({ handle: terminal.handle, agentIdentity: 'dsb' }) + ]) + expect(JSON.parse(JSON.stringify(listing)).terminals[0].agentIdentity).toBe('dsb') + + const submission = runtime.sendTerminalAgentPrompt(terminal.handle, 'review this', { + inputKind: 'driving', + leadLine: 'Please review' + }) + await Promise.all([ + expect(submission).resolves.toMatchObject({ accepted: true }), + vi.runAllTimersAsync() + ]) + expect(writes).toEqual([buildAgentPromptPasteBytes('review this', 'Please review'), '\r']) + }) +}) 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 d7aa946f477..29c1a636ffa 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 @@ -14,10 +14,30 @@ import { getRegisteredSshState } from '../ssh/ssh-target-registry' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalAgent } from '../../shared/terminal-agent' import type { AgentPromptActivity } from './agent-prompt-submission-verification' import { readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends OrcaRuntimeWithAgentPromptRequestCorrelation { + readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { + const pty = this.ptysById.get(ptyId) + if (!pty || pty.incarnationId !== incarnationId) { + return null + } + // The runtime launch route admits identity immediately after low-level spawn returns. + if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) { + return 'pending' as const + } + if ( + pty.launchIncarnationId !== incarnationId || + pty.launchToken !== launchToken || + (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') + ) { + return null + } + return this.terminalRunFacts.read(ptyId, incarnationId) + } + protected resolveAuthoritativeTerminalWaitPermission( terminal: RuntimeTerminalAgentStatusSnapshot, explicitStatus: { status: AgentStatus; updatedAt: number } | null, @@ -165,7 +185,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'linux' } - protected getPtyAgent(ptyId: string): TuiAgent | null { + protected getPtyAgent(ptyId: string): TerminalAgent | null { const pty = this.ptysById.get(ptyId) return pty?.launchAgent ?? pty?.foregroundAgent ?? null } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 5a471276d85..bc31a614cb9 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -4,6 +4,7 @@ import { preserveTerminalRetirementProofs } from './mobile-session-terminal-reti import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import type { TuiAgent } from '../../shared/tui-agent' +import { isTuiAgent } from '../../shared/tui-agent-config' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeClientSettingsController } from './runtime-client-settings' import type { RuntimeAutomationController } from './runtime-automation-controller' @@ -136,12 +137,8 @@ export class OrcaRuntimeWithRuntimeId { Date.now() ) - // Why: renderer publication ordering must be judged against the renderer's - // own last-accepted (epoch, version) — never against the stored snapshot's - // version, which main-local touches bump independently and can push - // permanently ahead of the renderer's counter. The renderer reuses one pair - // for byte-identical content, so a same-epoch version <= this one is a no-op - // resend (or stale) and is skipped without touching the stored entry. + // Main-local touches advance stored versions; reject unchanged renderer resends using + // the renderer's last accepted epoch/version instead. protected acceptedRendererMobileSnapshotByWorktree = new Map< string, { @@ -281,7 +278,8 @@ export class OrcaRuntimeWithRuntimeId { return null } const pty = this.ptysById.get(ptyId) - return pty?.launchAgent ?? pty?.foregroundAgent ?? null + const agent = pty?.launchAgent ?? pty?.foregroundAgent ?? null + return isTuiAgent(agent) ? agent : null } /** One-shot delivery retries, keyed by leaf. See checkDeliverySettledAndArmRecheck. */ diff --git a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts index 5ce8d9710cd..4ac28674dd8 100644 --- a/src/main/runtime/orca-runtime-tail-wait-memo.test.ts +++ b/src/main/runtime/orca-runtime-tail-wait-memo.test.ts @@ -1,124 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { - appendNormalizedToTailBuffer, - buildPreview, - computeTerminalTailWaitState, - tailGainedNewerBlockedReason, - type TerminalTailWaitState -} from './orca-runtime' +import { computeTerminalTailWaitState } from './terminal-wait-tail-state' -// These tests pin the onPtyData wait-detection memoization: caching the -// post-append wait state and reusing it as the next chunk's pre-append state -// must produce byte-for-byte the same waitBlockedAt stamping as recomputing the -// wait-state scan on both sides of every chunk (the pre-memoization behavior), -// while doing roughly half the state computations. - -type RedrawCursor = ReturnType['redrawCursor'] - -type TailSim = { - tailBuffer: string[] - tailPartialLine: string - tailRedrawCursor: RedrawCursor - preview: string - waitBlockedAt: number | null - tailWaitState?: TerminalTailWaitState -} - -function newSim(): TailSim { - return { - tailBuffer: [], - tailPartialLine: '', - tailRedrawCursor: null, - preview: '', - waitBlockedAt: null - } -} - -type Compute = typeof computeTerminalTailWaitState - -// Mirrors the memoized onPtyData tail loop: reuse the cached tail-derived state -// as the previous state; only recompute it on a preview-fallback (empty tail). -function stepMemoized(sim: TailSim, chunk: string, at: number, compute: Compute): void { - const previousWaitState = - sim.tailWaitState?.fromTail === true - ? sim.tailWaitState - : compute(sim.tailBuffer, sim.tailPartialLine, sim.preview) - const nextTail = appendNormalizedToTailBuffer( - sim.tailBuffer, - sim.tailPartialLine, - chunk, - sim.tailRedrawCursor - ) - const nextWaitState = compute(nextTail.lines, nextTail.partialLine, sim.preview) - if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, chunk)) { - sim.waitBlockedAt = at - } - sim.tailWaitState = nextWaitState - sim.tailBuffer = nextTail.lines - sim.tailPartialLine = nextTail.partialLine - sim.tailRedrawCursor = nextTail.redrawCursor - sim.preview = buildPreview(nextTail.lines, nextTail.partialLine) -} - -// Reference: the pre-memoization behavior — recompute the previous state fresh -// from the current tail on every chunk (no cache). -function stepReference(sim: TailSim, chunk: string, at: number, compute: Compute): void { - const previousWaitState = compute(sim.tailBuffer, sim.tailPartialLine, sim.preview) - const nextTail = appendNormalizedToTailBuffer( - sim.tailBuffer, - sim.tailPartialLine, - chunk, - sim.tailRedrawCursor - ) - const nextWaitState = compute(nextTail.lines, nextTail.partialLine, sim.preview) - if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, chunk)) { - sim.waitBlockedAt = at - } - sim.tailBuffer = nextTail.lines - sim.tailPartialLine = nextTail.partialLine - sim.tailRedrawCursor = nextTail.redrawCursor - sim.preview = buildPreview(nextTail.lines, nextTail.partialLine) -} - -function runBoth(chunks: string[]): { memoized: (number | null)[]; reference: (number | null)[] } { - const memoSim = newSim() - const refSim = newSim() - const memoized: (number | null)[] = [] - const reference: (number | null)[] = [] - chunks.forEach((chunk, index) => { - const at = index + 1 - stepMemoized(memoSim, chunk, at, computeTerminalTailWaitState) - stepReference(refSim, chunk, at, computeTerminalTailWaitState) - memoized.push(memoSim.waitBlockedAt) - reference.push(refSim.waitBlockedAt) - }) - return { memoized, reference } -} - -const SCENARIOS: Record = { - 'plain output never blocks': ['building...\n', 'compiled ok\n', 'watching for changes\n'], - 'blocked prompt in one chunk': ['Update available! Press Enter to continue.\n'], - 'blocked prompt split across chunks': ['Update available!\n', 'Press Enter to continue.\n'], - 'blocked then plain output stays blocked': [ - 'Update available! Press Enter to continue.\n', - 'still here\n', - 'more logs\n' - ], - 'partial lines without newline then completion': [ - 'Update ava', - 'ilable! Press Enter ', - 'to continue.\n' - ], - 'empty and whitespace chunks': ['', ' ', '\n', 'ok\n', ''], - 'ready header after stale blocked prompt': [ - 'Update available! Press Enter to continue.\n', - 'OpenAI Codex\n', - 'model: gpt\n', - 'directory: /repo\n' - ] -} - -describe('onPtyData tail wait memoization', () => { +describe('terminal tail wait state', () => { it('computeTerminalTailWaitState reports fromTail and blocked signals', () => { const empty = computeTerminalTailWaitState([], '', '') expect(empty.fromTail).toBe(false) @@ -151,92 +34,4 @@ describe('onPtyData tail wait memoization', () => { lastIndexOf.mockRestore() } }) - - for (const [name, chunks] of Object.entries(SCENARIOS)) { - it(`memoized stamping matches recompute reference: ${name}`, () => { - const { memoized, reference } = runBoth(chunks) - expect(memoized).toEqual(reference) - }) - } - - it('stays equivalent across tail eviction beyond the retained cap', () => { - const chunks: string[] = [] - for (let i = 0; i < 2600; i += 1) { - chunks.push(`line ${i} of streaming build output that keeps the tail busy\n`) - } - // Introduce a real blocked prompt well past the eviction boundary. - chunks.push('Update available! Press Enter to continue.\n') - chunks.push('trailing log after prompt\n') - const { memoized, reference } = runBoth(chunks) - expect(memoized).toEqual(reference) - // The prompt must actually be detected (guards against a vacuous match). - expect(memoized.at(-1)).not.toBeNull() - }) - - it('recomputes correctly after a transcript prune empties the tail (prune-then-resume)', () => { - // pruneDisconnectedPtyTranscript empties the tail AND clears tailWaitState; - // model that here and assert the memoized stamping still tracks a fresh - // recompute across the reset (a stale cache would desync the first resumed - // chunk, since the pre-prune tail held a blocked prompt). - const prune = (sim: TailSim): void => { - sim.tailBuffer = [] - sim.tailPartialLine = '' - sim.tailRedrawCursor = null - sim.preview = '' - sim.waitBlockedAt = null - sim.tailWaitState = undefined - } - const memoSim = newSim() - const refSim = newSim() - const memoOut: (number | null)[] = [] - const refOut: (number | null)[] = [] - let at = 0 - const feed = (chunk: string): void => { - at += 1 - stepMemoized(memoSim, chunk, at, computeTerminalTailWaitState) - stepReference(refSim, chunk, at, computeTerminalTailWaitState) - memoOut.push(memoSim.waitBlockedAt) - refOut.push(refSim.waitBlockedAt) - } - // Pre-prune: leave a stale blocked prompt in the tail. - ;['building\n', 'Update available! Press Enter to continue.\n', 'more log\n'].forEach(feed) - prune(memoSim) - prune(refSim) - // Resume: a fresh blocked prompt must be stamped, not masked by stale cache. - ;['fresh start\n', 'Update available! Press Enter to continue.\n', 'after\n'].forEach(feed) - - expect(memoOut).toEqual(refOut) - expect(memoSim.waitBlockedAt).not.toBeNull() - }) - - it('does roughly half the wait-state computations of the recompute reference', () => { - const chunks: string[] = [] - for (let i = 0; i < 500; i += 1) { - chunks.push(`streaming line ${i}\n`) - } - - let memoCalls = 0 - const countingMemo: Compute = (lines, partial, preview) => { - memoCalls += 1 - return computeTerminalTailWaitState(lines, partial, preview) - } - let refCalls = 0 - const countingRef: Compute = (lines, partial, preview) => { - refCalls += 1 - return computeTerminalTailWaitState(lines, partial, preview) - } - - const memoSim = newSim() - const refSim = newSim() - chunks.forEach((chunk, index) => { - stepMemoized(memoSim, chunk, index + 1, countingMemo) - stepReference(refSim, chunk, index + 1, countingRef) - }) - - // Reference recomputes both sides every chunk: 2 per chunk. - expect(refCalls).toBe(chunks.length * 2) - // Memoized reuses the cached previous state on every non-empty-tail chunk: - // 1 per chunk plus a single first-chunk cold miss. - expect(memoCalls).toBe(chunks.length + 1) - }) }) diff --git a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts index 69878468ccb..139e018025a 100644 --- a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts +++ b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review.spec.ts @@ -319,7 +319,8 @@ describe('OrcaRuntimeService', () => { 12, issueUpdates, null, - localGitOptions + localGitOptions, + undefined ) expect(addGitHubIssueCommentMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -327,7 +328,8 @@ describe('OrcaRuntimeService', () => { 'Comment', null, null, - localGitOptions + localGitOptions, + undefined ) expect(listGitHubLabelsMock).toHaveBeenCalledWith( TEST_REPO_PATH, diff --git a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts index 66d6e50335d..2d95f3bf3e5 100644 --- a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts +++ b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts @@ -6,6 +6,7 @@ import type { RuntimeLeafRecord } from './runtime-terminal-state-records' import type { ExecutionHostId } from '../../shared/execution-host' import { getPtyExecutionHost } from '../../shared/terminal-execution-host' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalAgent } from '../../shared/terminal-agent' import { selectRuntimeHookAgentRowForPane } from './runtime-mobile-agent-status-projection' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolvePublishedPaneAgentIdentity } from '../../shared/published-pane-agent-identity' @@ -89,10 +90,10 @@ export class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRuntimeWith protected resolvePaneAgentIdentityField( launchAgent: TuiAgent | null | undefined, - foregroundAgent: TuiAgent | null | undefined, + foregroundAgent: TerminalAgent | null | undefined, title: string | null, paneKey: string | null - ): { agentIdentity?: TuiAgent } { + ): { agentIdentity?: TerminalAgent } { const hookRow = paneKey ? selectRuntimeHookAgentRowForPane(this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? []) : null diff --git a/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts b/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts index 296bcea42bc..7884141973e 100644 --- a/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts +++ b/src/main/runtime/orchestration/db/decision-gates/decision-gate-store.ts @@ -3,6 +3,7 @@ import { OrchestrationError } from '../../orchestration-error' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' import { transitionLifecycleWithDb } from '../lifecycle-transition' +import { DISPATCH_CONTEXT_COLUMN_LIST } from '../row-column-lists' // ── Decision Gates ── @@ -27,13 +28,15 @@ export function createGate( } const runId = task.run_id this.requireRun(runId) - const active = this.db + const rawActive = this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1` ) - .get(gate.taskId) as DispatchContextRow | undefined + .get(gate.taskId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns one Dispatch row or undefined; the adapter exposes unknown. + const active = rawActive as DispatchContextRow | undefined if ( gate.requester && (!active || diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts index be1cf99d2b4..5821e5c5463 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion.ts @@ -3,6 +3,7 @@ import { OrchestrationError } from '../../orchestration-error' import { DISPATCH_CIRCUIT_BREAK_FAILURES } from './dispatch-circuit-breaker' import type { OrchestrationDb } from '../orchestration-db' import { getActiveDispatchForTask } from './task-dispatch-reconciliation' +import { DISPATCH_CONTEXT_COLUMN_LIST } from '../row-column-lists' import { beginLifecycleWriteTransaction, commitLifecycleWriteTransaction, @@ -46,11 +47,13 @@ export function settleActiveDispatchesForTask( status: 'completed' | 'failed', failure?: string ): void { - const rows = db.db + const rawRows = db.db .prepare( - "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched')" + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched')` ) - .all(taskId) as DispatchContextRow[] + .all(taskId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing complete Dispatch projection is pinned to this table's schema by row-column-lists.test.ts. + const rows = rawRows as DispatchContextRow[] for (const row of rows) { transitionLifecycleWithDb(db.db, { entity: 'dispatch', @@ -97,15 +100,17 @@ export function getStaleDispatches( this: OrchestrationDb, thresholdIso: string ): DispatchContextRow[] { - return this.db + const rows = this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE status = 'dispatched' AND dispatched_at IS NOT NULL AND julianday(dispatched_at) < julianday(?) AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))` ) - .all(thresholdIso, thresholdIso) as DispatchContextRow[] + .all(thresholdIso, thresholdIso) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing complete Dispatch projection is pinned to this table's schema by row-column-lists.test.ts. + return rows as DispatchContextRow[] } export function failDispatch( @@ -164,9 +169,11 @@ export function failDispatch( capability_revoked_at: before.capability_revoked_at ?? new Date().toISOString() } }) - const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as - | DispatchContextRow - | undefined + const rawContext = this.db + .prepare(`SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE id = ?`) + .get(ctxId) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns one Dispatch row or undefined; the adapter exposes unknown. + const ctx = rawContext as DispatchContextRow | undefined if (!ctx) { commitLifecycleWriteTransaction(this.db, transaction) return undefined diff --git a/src/main/runtime/orchestration/db/dispatch-depth.ts b/src/main/runtime/orchestration/db/dispatch-depth.ts index 93c5bd65fb6..32020aa3b3f 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.ts @@ -10,6 +10,7 @@ import type { OrchestrationDb } from './orchestration-db' import type { OrcaSessionId } from '../../../../shared/orca-session-address' import type { DispatchContextRow, RemoteDispatchAttachmentRow } from '../types' import { potentiallyLiveRemoteAttachmentSql } from './federation/remote-attachment-liveness' +import { DISPATCH_CONTEXT_COLUMN_LIST } from './row-column-lists' /** * Who is creating a dispatch row, for nesting-depth purposes. @@ -147,12 +148,12 @@ function findActiveDispatchForCreator( } const row = this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE assignee_orca_session_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1` ) .get(creator.orcaSessionId) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns one Dispatch row or undefined; the adapter exposes unknown. return row as DispatchContextRow | undefined } diff --git a/src/main/runtime/orchestration/db/legacy/legacy-compatibility-candidates.ts b/src/main/runtime/orchestration/db/legacy/legacy-compatibility-candidates.ts index d94c89ac4b2..24cb88be578 100644 --- a/src/main/runtime/orchestration/db/legacy/legacy-compatibility-candidates.ts +++ b/src/main/runtime/orchestration/db/legacy/legacy-compatibility-candidates.ts @@ -7,6 +7,7 @@ import { OrchestrationError } from '../../orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../contract-constants' import { isEquivalentPaneKey } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +import { DISPATCH_CONTEXT_COLUMN_LIST } from '../row-column-lists' export function resolveLegacyCompatibilityPrincipalByIdentity( this: OrchestrationDb, @@ -60,9 +61,10 @@ export function resolveLegacyWorkerCandidate( ? [this.getDispatchContextById(params.dispatchId)].filter( (row): row is DispatchContextRow => row !== undefined ) - : (this.db + : // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing complete Dispatch projection is pinned to this table's schema by row-column-lists.test.ts. + (this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE run_id = ? AND contract_version = ? AND status IN ('pending', 'dispatched') ORDER BY rowid` diff --git a/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts b/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts index 1b5afd1fbcb..f8ebfb4762c 100644 --- a/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts +++ b/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts @@ -2,6 +2,7 @@ import { parsePaneKey } from '../../../../../shared/stable-pane-id' import type { DispatchContextRow, MessageType } from '../../types' import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +import { DISPATCH_CONTEXT_COLUMN_LIST } from '../row-column-lists' import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type ForeignDirectMailboxRoutingPage @@ -13,25 +14,29 @@ export function findActiveDispatchForDirectMessageOwner( directHandle: string, paneKey?: string ): DispatchContextRow | undefined { - const exact = this.db + const rawExact = this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE run_id = ? AND assignee_handle = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1` ) - .get(runId, directHandle) as DispatchContextRow | undefined + .get(runId, directHandle) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns one Dispatch row or undefined; the adapter exposes unknown. + const exact = rawExact as DispatchContextRow | undefined if (exact || !paneKey || !parsePaneKey(paneKey)) { return exact } - return this.db + const reminted = this.db .prepare( - `SELECT * FROM dispatch_contexts + `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE run_id = ? AND assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1 AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ? ORDER BY rowid DESC LIMIT 1` ) - .get(runId, paneKeyMatchSuffix(paneKey)) as DispatchContextRow | undefined + .get(runId, paneKeyMatchSuffix(paneKey)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The schema-pinned complete Dispatch projection returns one Dispatch row or undefined; the adapter exposes unknown. + return reminted as DispatchContextRow | undefined } export function routeForeignDirectMessagesToOwnedMailboxes( diff --git a/src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts b/src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts new file mode 100644 index 00000000000..3873f628500 --- /dev/null +++ b/src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts @@ -0,0 +1,295 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isOrcaSessionId } from '../../../../shared/orca-session-address' +import { OrchestrationDb } from './orchestration-db' +import { createRootDispatch } from './root-dispatch-test-fixture' +import { LEGACY_CONTRACT_VERSION } from './contract-constants' +import { DISPATCH_CONTEXT_COLUMNS } from './row-column-lists' +import { warnStaleDispatches } from '../coordinator-task-dispatch' + +const databases: OrchestrationDb[] = [] +const directories: string[] = [] +const NOW = '2026-10-02T12:00:00.000Z' +const PANE = 'tab_worker:11111111-1111-4111-8111-111111111111' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + for (const db of databases.splice(0)) { + db.close() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(count = 1, version: 'fresh' | 'v41' = 'fresh') { + const directory = mkdtempSync(join(tmpdir(), 'orca-remaining-dispatch-compilation-')) + directories.push(directory) + const path = join(directory, 'orchestration.db') + let writer = new OrchestrationDb(path) + databases.push(writer) + const run = writer.createRun({ + objective: 'dispatch lifecycle', + coordinatorHandle: 'coordinator', + coordinatorPaneKey: 'tab_coordinator:22222222-2222-4222-8222-222222222222' + }) + const entries = Array.from({ length: count }, (_, index) => { + const task = writer.createTask({ spec: `work ${index}`, runId: run.id }) + const pane = + index === 0 + ? PANE + : `tab_worker:${String(index).padStart(8, '0')}-1111-4111-8111-111111111111` + const dispatch = createRootDispatch(writer, task.id, `worker_${index}`, pane) + writer.db + .prepare('UPDATE dispatch_contexts SET status = ?, dispatched_at = ?, depth = ? WHERE id = ?') + .run('dispatched', '2026-10-02 10:00:00', 2, dispatch.id) + writer.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('dispatched', task.id) + const expected = writer.db + .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') + .get(dispatch.id) + if (!expected) { + throw new Error('Missing dispatch fixture') + } + return { task, dispatch, expected } + }) + if (version === 'v41') { + writer.db.exec(` + DROP INDEX idx_runs_coordinator_orca_session_id; + DROP INDEX idx_dispatch_assignee_orca_session_id; + DROP TRIGGER trg_runs_remember_coordinator_insert; + DROP TRIGGER trg_runs_remember_coordinator_update; + DROP TRIGGER trg_messages_route_coordinator_mail; + ALTER TABLE runs DROP COLUMN coordinator_orca_session_id; + ALTER TABLE runs DROP COLUMN coordinator_orca_session_id_generation; + ALTER TABLE dispatch_contexts DROP COLUMN assignee_orca_session_id; + ALTER TABLE dispatch_contexts DROP COLUMN creator_orca_session_id; + `) + writer.db.pragma('user_version = 41') + databases.pop() + writer.close() + writer = new OrchestrationDb(path) + databases.push(writer) + for (const entry of entries) { + const migrated = writer.db + .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') + .get(entry.dispatch.id) + if (!migrated) { + throw new Error('Missing migrated dispatch') + } + entry.expected = migrated + } + } + expect( + writer.db + .prepare('PRAGMA table_info(dispatch_contexts)') + .all() + .map((row) => row.name) + .toSorted() + ).toEqual(DISPATCH_CONTEXT_COLUMNS.toSorted()) + const reader = new OrchestrationDb(path) + databases.push(reader) + return { writer, reader, run, entries } +} + +function fullDispatchReads(calls: [string, ...unknown[]][]): string[] { + return calls + .map(([sql]) => sql) + .filter( + (sql) => + /SELECT (?:\*|id, run_id, task_id, contract_version,)/.test(sql) && + sql.includes('FROM dispatch_contexts') + ) +} + +describe('remaining dispatch metadata statement compilation', () => { + it.each(['fresh', 'v41'] as const)( + 'reuses stale and direct-owner reads with exact rows, rebinding, plans and cross-connection freshness (%s)', + (version) => { + const { + writer, + reader, + run, + entries: [{ dispatch, expected }] + } = fixture(1, version) + vi.useFakeTimers() + vi.setSystemTime(new Date(NOW)) + const compile = vi.spyOn(DatabaseSync.prototype, 'prepare') + for (let call = 0; call < 10; call += 1) { + expect(reader.getStaleDispatches('2026-10-02T11:00:00Z')).toEqual([expected]) + expect(reader.getStaleDispatches('2026-10-02T09:00:00Z')).toEqual([]) + expect(reader.findActiveDispatchForDirectMessageOwner(run.id, 'worker_0')).toEqual(expected) + expect(reader.findActiveDispatchForDirectMessageOwner(run.id, 'missing', PANE)).toEqual( + expected + ) + expect( + reader.findActiveDispatchForDirectMessageOwner( + run.id, + 'missing', + PANE.replace('tab_worker:', 'reminted:') + ) + ).toEqual(expected) + expect( + reader.findActiveDispatchForDirectMessageOwner(run.id, 'missing', 'bad:legacy') + ).toBeUndefined() + } + const warnings: string[] = [] + warnStaleDispatches(reader, (warning) => warnings.push(warning)) + expect(warnings).toEqual([ + `Warning: worker worker_0 on task ${dispatch.task_id} has not sent a heartbeat in ~10 min (dispatch ${dispatch.id})` + ]) + writer.db + .prepare( + 'UPDATE dispatch_contexts SET last_heartbeat_at = ?, assignee_handle = ? WHERE id = ?' + ) + .run(NOW, 'updated', dispatch.id) + expect(reader.getStaleDispatches('2026-10-02T11:00:00Z')).toEqual([]) + expect(reader.findActiveDispatchForDirectMessageOwner(run.id, 'worker_0')).toBeUndefined() + expect(reader.findActiveDispatchForDirectMessageOwner(run.id, 'updated')).toEqual({ + ...expected, + last_heartbeat_at: NOW, + assignee_handle: 'updated' + }) + const reads = fullDispatchReads(compile.mock.calls) + const staleSql = reads.find((sql) => sql.includes('julianday')) + compile.mockRestore() + if (!staleSql) { + throw new Error('Missing stale query') + } + const wildcardSql = staleSql.replace(/SELECT .*? FROM/, 'SELECT * FROM') + expect(writer.db.prepare(`EXPLAIN QUERY PLAN ${staleSql}`).all(NOW, NOW)).toEqual( + writer.db.prepare(`EXPLAIN QUERY PLAN ${wildcardSql}`).all(NOW, NOW) + ) + expect(reads).toHaveLength(3) + } + ) + + it.each(['fresh', 'v41'] as const)( + 'reuses session creator reads without changing self-created parent exclusion or fresh depth (%s)', + (version) => { + const { + writer, + reader, + entries: [{ dispatch }] + } = fixture(1, version) + const sessionId = '00000000-0000-4000-8000-000000000001' + if (!isOrcaSessionId(sessionId)) { + throw new Error('Invalid session fixture') + } + writer.db + .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') + .run(sessionId, dispatch.id) + const compile = vi.spyOn(DatabaseSync.prototype, 'prepare') + for (let call = 0; call < 10; call += 1) { + expect(reader.resolveCreatorDepth({ kind: 'session', orcaSessionId: sessionId })).toBe(2) + expect(reader.resolveCreatorDispatchId({ kind: 'session', orcaSessionId: sessionId })).toBe( + dispatch.id + ) + } + writer.db.prepare('UPDATE dispatch_contexts SET depth = ? WHERE id = ?').run(3, dispatch.id) + expect(reader.resolveCreatorDepth({ kind: 'session', orcaSessionId: sessionId })).toBe(3) + writer.db + .prepare('UPDATE dispatch_contexts SET creator_handle = assignee_handle WHERE id = ?') + .run(dispatch.id) + expect(reader.resolveCreatorDepth({ kind: 'session', orcaSessionId: sessionId })).toBe(0) + expect( + reader.resolveCreatorDispatchId({ kind: 'session', orcaSessionId: sessionId }) + ).toBeNull() + expect(fullDispatchReads(compile.mock.calls)).toHaveLength(1) + } + ) + + it.each(['fresh', 'v41'] as const)( + 'reuses gate ownership and settlement reads while preserving completed rows and blocked tasks (%s)', + (version) => { + vi.useFakeTimers() + vi.setSystemTime(new Date(NOW)) + const { reader, entries } = fixture(10, version) + const compile = vi.spyOn(DatabaseSync.prototype, 'prepare') + for (const { task, dispatch, expected } of entries) { + const gate = reader.createGate({ taskId: task.id, question: 'Continue?' }) + expect(gate.task_id).toBe(task.id) + expect(gate.status).toBe('pending') + expect(reader.getDispatchContextById(dispatch.id)).toEqual({ + ...expected, + status: 'completed', + completed_at: NOW, + capability_revoked_at: NOW + }) + expect(reader.getTask(task.id)?.status).toBe('blocked') + } + expect(fullDispatchReads(compile.mock.calls)).toHaveLength(12) + } + ) + + it.each(['fresh', 'v41'] as const)( + 'reuses the private failure snapshot while preserving returned committed row order (%s)', + (version) => { + vi.useFakeTimers() + vi.setSystemTime(new Date(NOW)) + const { reader, entries } = fixture(10, version) + const compile = vi.spyOn(DatabaseSync.prototype, 'prepare') + for (const { task, dispatch, expected } of entries) { + const expectedFailure = { + ...expected, + status: 'failed', + failure_count: 1, + last_failure: 'failed send', + completed_at: NOW, + capability_revoked_at: NOW + } + const failure = reader.failDispatch(dispatch.id, 'failed send') + expect(failure).toEqual(expectedFailure) + expect(JSON.stringify(failure)).toBe(JSON.stringify(expectedFailure)) + expect(reader.getTask(task.id)?.status).toBe('ready') + } + const reads = fullDispatchReads(compile.mock.calls) + compile.mockRestore() + const first = entries[0] + if (!first) { + throw new Error('Missing failure fixture') + } + const before = reader.db + .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') + .get(first.dispatch.id) + const early = reader.failDispatch(first.dispatch.id, 'later') + expect(JSON.stringify(early)).toBe(JSON.stringify(before)) + expect(early).not.toBe(reader.failDispatch(first.dispatch.id, 'later')) + expect(reads).toHaveLength(21) + } + ) + + it.each(['fresh', 'v41'] as const)( + 'reuses legacy dispatch identity scans with full rows and immediately fences settled rows (%s)', + (version) => { + const { + writer, + reader, + run, + entries: [{ dispatch, expected }] + } = fixture(1, version) + writer.db + .prepare('UPDATE dispatch_contexts SET contract_version = ? WHERE id = ?') + .run(LEGACY_CONTRACT_VERSION, dispatch.id) + const compile = vi.spyOn(DatabaseSync.prototype, 'prepare') + for (let call = 0; call < 10; call += 1) { + expect( + reader.resolveLegacyWorkerCandidate({ runId: run.id, terminalHandle: 'worker_0' }) + ).toEqual({ dispatch: { ...expected, contract_version: LEGACY_CONTRACT_VERSION } }) + expect( + reader.resolveLegacyWorkerCandidate({ runId: run.id, terminalHandle: 'missing' }) + ).toBeUndefined() + } + writer.db + .prepare('UPDATE dispatch_contexts SET status = ? WHERE id = ?') + .run('completed', dispatch.id) + expect( + reader.resolveLegacyWorkerCandidate({ runId: run.id, terminalHandle: 'worker_0' }) + ).toBeUndefined() + expect(fullDispatchReads(compile.mock.calls)).toHaveLength(1) + } + ) +}) diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 0a54481c2e1..ca9c7edd162 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -46,6 +46,16 @@ describe('isGroupAddress', () => { }) describe('resolveGroupAddress', () => { + it('keeps observed DSB recipients out of existing agent-name groups', () => { + const terminals = [ + makeSummary('term_dsb', { agentIdentity: 'dsb', title: 'Review Claude - DeepSeek Build' }) + ] + expect(resolveGroupAddress('@claude', 'sender', terminals, noStatus)).toEqual([]) + expect(resolveGroupAddress('@codex', 'sender', terminals, noStatus)).toEqual([]) + expect(resolveGroupAddress('@dsb', 'sender', terminals, noStatus)).toEqual([]) + expect(resolveGroupAddress('@all', 'sender', terminals, noStatus)).toEqual(['term_dsb']) + }) + it('returns the address as-is for non-group addresses', () => { const result = resolveGroupAddress('term_b', 'term_a', [], noStatus) expect(result).toEqual(['term_b']) diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index 3a6161f8187..34df31f50ee 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -16,6 +16,7 @@ */ import type { TuiAgent } from '../../../shared/tui-agent' +import type { TerminalAgent } from '../../../shared/terminal-agent' import { structuredWorkerAgent } from '../structured-worker-authority' import { structuredWorkerAddressable } from '../structured-worker-custody' import { @@ -30,7 +31,7 @@ export type OrchestrationAddressableAgent = { handle: string worktreeId: string /** Absent means "unknown", and `@claude`/`@codex` fail closed on it, exactly as for a pane. */ - agentIdentity?: TuiAgent + agentIdentity?: TerminalAgent } /** diff --git a/src/main/runtime/pty-transcript-prune-wait-cache.test.ts b/src/main/runtime/pty-transcript-prune-wait-cache.test.ts index 6c9082d2552..6a99c7b25ee 100644 --- a/src/main/runtime/pty-transcript-prune-wait-cache.test.ts +++ b/src/main/runtime/pty-transcript-prune-wait-cache.test.ts @@ -1,14 +1,9 @@ -/** - * Regression: pruneDisconnectedPtyTranscript empties a disconnected PTY's - * retained tail. The onPtyData wait-scan memoization caches the tail's wait - * state on the record (tailWaitState) and reuses it as the next chunk's - * "previous" state — so the prune MUST also clear that cache, or a record that - * resumes output after adoption/reattach would reuse a stale (pre-prune, - * possibly blocked) wait state and mis-stamp waitBlockedAt on its first chunk. - */ -import { describe, expect, it } from 'vitest' +// A disconnected transcript must not keep a wait-scan cache from before its history was pruned. +import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' -import type { TerminalTailWaitState } from './orca-runtime' +import { MAX_TAIL_LINES } from './terminal-tail-limits' +import * as terminalWaitTailState from './terminal-wait-tail-state' +import type { TerminalTailWaitState } from './terminal-wait-tail-state' type PtyRecord = { connected: boolean @@ -20,9 +15,33 @@ type RuntimeInternals = { pruneDisconnectedPtyTranscript: (pty: PtyRecord) => void } +function onlyRuntimeLeaf(runtime: unknown) { + if (typeof runtime !== 'object' || runtime === null || !('leaves' in runtime)) { + throw new Error('Runtime has no leaves') + } + const leaves = runtime.leaves + if (!(leaves instanceof Map) || leaves.size !== 1) { + throw new Error('Expected exactly one runtime leaf') + } + const leaf: unknown = leaves.values().next().value + if ( + typeof leaf !== 'object' || + leaf === null || + !('tailBuffer' in leaf) || + !Array.isArray(leaf.tailBuffer) || + !('tailLinesTotal' in leaf) || + typeof leaf.tailLinesTotal !== 'number' || + !('waitBlockedAt' in leaf) + ) { + throw new Error('Runtime leaf has no terminal tail state') + } + return leaf +} + describe('pruneDisconnectedPtyTranscript clears the wait-scan cache', () => { it('empties the tail and drops tailWaitState so resume recomputes', () => { const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both protected methods are declared on OrcaRuntimeService's inheritance chain. const internals = runtime as unknown as RuntimeInternals const pty = internals.recordPtyWorktree('pty-1', 'wt-1', { connected: true }) @@ -40,4 +59,65 @@ describe('pruneDisconnectedPtyTranscript clears the wait-scan cache', () => { expect(pty.tailBuffer).toEqual([]) expect(pty.tailWaitState).toBeUndefined() }) + + it('reuses the real leaf wait scan and stamps each newly arriving blocked prompt', async () => { + const runtime = new OrcaRuntimeService() + const ptyId = 'pty-memo' + const leafId = '11111111-1111-4111-8111-111111111111' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [{ tabId: 'tab-1', worktreeId: 'wt-1', title: '', activeLeafId: leafId, layout: null }], + leaves: [ + { tabId: 'tab-1', worktreeId: 'wt-1', leafId, paneRuntimeId: 1, ptyId, paneTitle: null } + ] + }) + const leaf = onlyRuntimeLeaf(runtime) + // Different retained history exercises the leaf scan rather than the shared PTY-tail path. + leaf.tailBuffer = ['leaf-only history'] + leaf.tailLinesTotal = 1 + const scan = vi.spyOn(terminalWaitTailState, 'computeTerminalTailWaitState') + const leafScans = () => + scan.mock.calls.filter(([lines]) => lines.includes('leaf-only history')).length + try { + runtime.onPtyData(ptyId, 'first plain line\n', 1_000) + expect(leafScans()).toBe(2) + expect('tailWaitState' in leaf ? leaf.tailWaitState : undefined).toMatchObject({ + fromTail: true, + signal: null + }) + runtime.onPtyData(ptyId, 'second plain line\n', 2_000) + expect(leafScans()).toBe(3) + expect(leaf.waitBlockedAt).toBeNull() + runtime.onPtyData(ptyId, 'Update ava', 3_000) + expect(leafScans()).toBe(4) + expect(leaf.waitBlockedAt).toBeNull() + runtime.onPtyData(ptyId, 'ilable! Press Enter to continue.\n', 4_000) + expect(leafScans()).toBe(5) + expect(leaf.waitBlockedAt).toBe(4_000) + runtime.onPtyData(ptyId, 'ordinary log after the prompt\n', 5_000) + expect(leafScans()).toBe(6) + expect(leaf.waitBlockedAt).toBe(4_000) + runtime.onPtyData(ptyId, 'Update available! Press Enter to continue.\n', 6_000) + expect(leafScans()).toBe(7) + expect(leaf.waitBlockedAt).toBe(6_000) + runtime.onPtyData( + ptyId, + Array.from({ length: MAX_TAIL_LINES + 1 }, (_, index) => `streaming line ${index}\n`).join( + '' + ), + 7_000 + ) + expect(leaf.tailBuffer).toHaveLength(MAX_TAIL_LINES) + expect(leaf.tailBuffer).not.toContain('leaf-only history') + expect('tailWaitState' in leaf ? leaf.tailWaitState : undefined).toMatchObject({ + fromTail: true, + signal: null + }) + runtime.onPtyData(ptyId, 'Update available! Press Enter to continue.\n', 8_000) + expect(leaf.waitBlockedAt).toBe(8_000) + } finally { + scan.mockRestore() + await runtime.onPtyExit(ptyId, 0) + } + }) }) diff --git a/src/main/runtime/quarter-circle-title-send-authorization.test.ts b/src/main/runtime/quarter-circle-title-send-authorization.test.ts index 8def14f0d99..afaa8362409 100644 --- a/src/main/runtime/quarter-circle-title-send-authorization.test.ts +++ b/src/main/runtime/quarter-circle-title-send-authorization.test.ts @@ -1,7 +1,7 @@ // STA-4028 (regression from #13925): quarter circles are ordinary progress glyphs — // ora, installers, any TUI animates them — so a title carrying nothing else must not // authorize a guarded send, which auto-submits with Enter into whatever owns the pane. -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' import { detectAgentStatusFromTitle } from '../../shared/agent-detection' @@ -18,6 +18,11 @@ const TAB_ID = 'tab-1' const WORKTREE_ID = 'wt-1' const PTY_ID = 'pty-1' +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + // Captured from Claude Code 2.1.228 while it read this repository's package.json. const SPINNER_ONLY_TITLE = '◑ Check package version in package.json' const SPINNER_WITH_IDENTITY_TITLE = '◐ Claude Code' @@ -91,6 +96,42 @@ async function createRuntimeWithTitle( const AUTHORIZED = 'authorized' +// The 150ms foreground tick first crosses its unchanged 6,500ms budget at 6,600ms. +const WRAPPER_REJECTION_TICK_MS = 6_600 +const NO_AGENT_GUARD_DEADLINE_MS = 1_050 + +async function readAgentEvidenceAtDeadline( + read: () => Promise, + deadlineMs: number +): Promise { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + let settled = false + const result = read() + void result.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await vi.advanceTimersByTimeAsync(deadlineMs - 1) + expect(settled, 'agent evidence settled before its deadline').toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(settled, 'agent evidence did not settle at its deadline').toBe(true) + expect(vi.getTimerCount()).toBe(0) + return await result + } finally { + try { + await vi.runOnlyPendingTimersAsync() + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + } +} + async function guardedSendResult(runtime: OrcaRuntimeService, handle: string): Promise { try { await assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) @@ -104,10 +145,18 @@ describe('quarter-circle title send authorization (STA-4028)', () => { it('refuses a guarded send when a quarter-circle spinner is the only agent evidence', async () => { const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'node') - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: false - }) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => runtime.getTerminalAgentStatus(handle), + WRAPPER_REJECTION_TICK_MS + ) + ).toMatchObject({ isRunningAgent: false }) + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + WRAPPER_REJECTION_TICK_MS + ) + ).toBe('terminal_guard_no_agent') }) it('refuses a guarded send when the foreground process cannot be read at all', async () => { @@ -115,7 +164,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { // stays a refusal rather than falling back to the glyph. const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('authorizes a guarded send when the foreground process is a recognized agent', async () => { @@ -158,7 +212,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { false ) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('does not carry managed Claude identity into a replacement PTY incarnation', async () => { @@ -198,9 +257,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { await expect(runtime.getTerminalAgentStatus(replacementHandle)).resolves.toMatchObject({ isRunningAgent: false }) - await expect(guardedSendResult(runtime, replacementHandle)).resolves.toBe( - 'terminal_guard_no_agent' - ) + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, replacementHandle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('authorizes a guarded send when the busy title itself names the agent', async () => { diff --git a/src/main/runtime/readiness-census-transcript-catalog.ts b/src/main/runtime/readiness-census-transcript-catalog.ts index 812adca67e9..4ee9ee7c624 100644 --- a/src/main/runtime/readiness-census-transcript-catalog.ts +++ b/src/main/runtime/readiness-census-transcript-catalog.ts @@ -38,6 +38,8 @@ const RUNTIME_RECORDERS: readonly (readonly [string, Recorder])[] = [ 'cursor-agent-', { agent: 'cursor', foregroundProcess: 'cursor-agent', grid: { cols: 80, rows: 24 } } ], + // Build is observation-only, so its recordings do not establish TuiAgent readiness. + ['dsb-', { agent: null, foregroundProcess: 'dsb' }], ['dsh-', { agent: 'dsh', foregroundProcess: 'dsh-tui' }], ['freebuff-', { agent: 'freebuff', foregroundProcess: 'freebuff' }], ['hermes-', { agent: 'hermes', foregroundProcess: 'hermes' }], diff --git a/src/main/runtime/readiness-census.test.ts b/src/main/runtime/readiness-census.test.ts index e757acdcb7e..9a11abe7e18 100644 --- a/src/main/runtime/readiness-census.test.ts +++ b/src/main/runtime/readiness-census.test.ts @@ -52,6 +52,21 @@ describe('readiness census coverage', () => { ) }) + it('replays observation-only Build captures only on agent-unknown panes', () => { + const buildCaptures = readdirSync(join(__dirname, '__fixtures__')) + .filter((file) => file.startsWith('dsb-') && file.endsWith('.txt')) + .map((file) => file.slice(0, -'.txt'.length)) + const buildPanes = CENSUS_PANES.filter(({ transcript }) => + buildCaptures.includes(transcript.name) + ) + expect(buildPanes.map(censusPaneSubject).toSorted()).toEqual( + buildCaptures.map((name) => `transcript/${name}@unknown`).toSorted() + ) + for (const { transcript } of buildPanes) { + expect(transcript.agent).toBeNull() + } + }) + it('keeps exactly one baseline per replayed pane and synthetic agent', () => { const subjects = [ ...CENSUS_PANES.map(censusPaneSubject), diff --git a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts index 30b36483675..b11ce57dcfb 100644 --- a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts +++ b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - appendNormalizedToTailBuffer, - appendNormalizedToMultilineTailBufferUnwindowed -} from './orca-runtime' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { appendNormalizedToMultilineTailBufferUnwindowed } from './terminal-tail-redraw-buffer' // Differential guard for the windowed redraw tail path: the public // appendNormalizedToTailBuffer routes vertical-control chunks through a diff --git a/src/main/runtime/ripgrep-filename-identity.test.ts b/src/main/runtime/ripgrep-filename-identity.test.ts new file mode 100644 index 00000000000..b8ef50a76b6 --- /dev/null +++ b/src/main/runtime/ripgrep-filename-identity.test.ts @@ -0,0 +1,82 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { buildExcludePathPrefixes, normalizeQuickOpenRgLine } from '../../shared/quick-open-filter' +import { createAccumulator, ingestRgJsonLine } from '../../shared/text-search' + +it.each(['/native/repo\\root', '/ssh/repo\\root'])( + 'preserves POSIX search identities under %s independently of client platform', + (root) => { + const acc = createAccumulator() + for (const name of ['a\\b.txt', 'a/b.txt']) { + const relative = normalizeQuickOpenRgLine(`./${name}`, { kind: 'cwd-relative' }) + expect(relative).toBe(name) + expect( + normalizeQuickOpenRgLine(`${root}/${name}`, { kind: 'absolute', rootPath: root }) + ).toBe(name) + expect(joinWorktreeRelativePath(root, normalizeRuntimeRelativePath(name, root))).toBe( + `${root}/${name}` + ) + ingestRgJsonLine( + JSON.stringify({ + type: 'match', + data: { + path: { text: `${root}/${name}` }, + lines: { text: 'needle' }, + line_number: 1, + submatches: [{ start: 0, end: 6 }] + } + }), + root, + acc, + 10 + ) + } + expect([...acc.fileMap.values()].map((file) => file.relativePath)).toEqual([ + 'a\\b.txt', + 'a/b.txt' + ]) + expect(buildExcludePathPrefixes(root, [`${root}/a\\b`])).toEqual(['a\\b']) + } +) + +it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'preserves Windows separator compatibility under %s', + (root) => { + expect(joinWorktreeRelativePath(root, normalizeRuntimeRelativePath('a\\b.txt', root))).toBe( + `${root}\\a\\b.txt` + ) + expect( + normalizeQuickOpenRgLine(`${root}\\a\\b.txt`, { kind: 'absolute', rootPath: root }) + ).toBe('a/b.txt') + } +) + +describe.skipIf(process.platform === 'win32')('real POSIX filename collision', () => { + it('opens both literal-backslash and nested files without changing identity', async () => { + const parent = await mkdtemp(join(tmpdir(), 'orca-rg-path-')) + const root = join(parent, 'repo\\root') + try { + await mkdir(join(root, 'a'), { recursive: true }) + await writeFile(join(root, 'a\\b.txt'), 'literal') + await writeFile(join(root, 'a/b.txt'), 'nested') + for (const [name, expected] of [ + ['a\\b.txt', 'literal'], + ['a/b.txt', 'nested'] + ]) { + const relative = normalizeQuickOpenRgLine(`./${name}`, { kind: 'cwd-relative' }) + expect(relative).toBe(name) + expect( + await readFile( + joinWorktreeRelativePath(root, normalizeRuntimeRelativePath(name, root)), + 'utf8' + ) + ).toBe(expected) + } + } finally { + await rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/ai-vault-search.test.ts b/src/main/runtime/rpc/methods/ai-vault-search.test.ts index 16f998e84e9..c247b0f31b1 100644 --- a/src/main/runtime/rpc/methods/ai-vault-search.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -116,6 +116,40 @@ describe('session search runtime RPC', () => { } ) }) + it.each(['runtime', 'relay'] as const)( + 'searches when the real dispatcher is missing only status over %s', + async (transport) => { + const service = fakeSearchService() + setSessionSearchService(service) + const rpc = new RpcDispatcher({ + runtime: new OrcaRuntimeService(), + methods: AI_VAULT_METHODS.filter((method) => method.name !== 'aiVault.searchStatus') + }) + const replies: unknown[] = [] + const client = createSessionSearchClient(async (method, params) => { + const response = await rpc.dispatch({ ...request(params), method }) + replies.push(response) + if (!response.ok) { + throw Object.assign(new Error(response.error.message), { code: response.error.code }) + } + return response.result + }, transport) + expect( + await client.searchSessions({ + query: 'needle', + filters: { agents: ['claude'] } + }) + ).toMatchObject({ kind: 'results' }) + expect(replies).toMatchObject([ + { ok: false, error: { code: 'method_not_found' } }, + { ok: true } + ]) + expect(service.search).toHaveBeenCalledExactlyOnceWith( + { query: 'needle', limit: 20, filters: { agents: ['claude'] } }, + undefined + ) + } + ) it('maps the old runtime dispatcher refusal and rejects malformed responses', async () => { const legacy = dispatcher(true) const client = createSessionSearchClient(async (method, params) => { diff --git a/src/main/runtime/rpc/methods/github-issue-methods.ts b/src/main/runtime/rpc/methods/github-issue-methods.ts index 86300017741..825fe573d2f 100644 --- a/src/main/runtime/rpc/methods/github-issue-methods.ts +++ b/src/main/runtime/rpc/methods/github-issue-methods.ts @@ -35,6 +35,19 @@ export const GITHUB_ISSUE_METHODS = [ name: 'github.addIssueComment', params: IssueComment, handler: async (params, { runtime }) => - runtime.addRepoIssueComment(params.repo, params.number, params.body, params.prRepo ?? null) + params.type + ? runtime.addRepoIssueComment( + params.repo, + params.number, + params.body, + params.prRepo ?? null, + params.type + ) + : runtime.addRepoIssueComment( + params.repo, + params.number, + params.body, + params.prRepo ?? null + ) }) ] diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index d1bf9946806..1ed202d2f57 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -626,10 +626,13 @@ describe('github RPC methods', () => { }) ) - expect(runtime.addRepoIssueComment).toHaveBeenCalledWith('repo-1', 3, 'Looks good', { - owner: 'acme', - repo: 'widgets' - }) + expect(runtime.addRepoIssueComment).toHaveBeenCalledWith( + 'repo-1', + 3, + 'Looks good', + { owner: 'acme', repo: 'widgets' }, + 'pr' + ) expect(response).toMatchObject({ ok: true, result: { ok: true, comment: { id: 1 } } }) }) diff --git a/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts b/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts new file mode 100644 index 00000000000..358b723e1ac --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts @@ -0,0 +1,144 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../../../sqlite/sync-database' +import { asRecord } from '../../../ai-vault/session-scanner-values' +import { readOpenCodeTranscriptPage } from '../../../native-chat/transcript-opencode-sqlite-query' +import { readOpenCodeNativeChatTranscriptTail } from '../../../native-chat/transcript-opencode' +import type { SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch-contract' +import { buildRegistry, isStreamingMethod, type RpcContext } from '../core' + +const state: { path: string; watcher: SubscribeNativeChatTranscriptArgs | null } = vi.hoisted( + () => ({ + path: '', + watcher: null + }) +) +vi.mock('../../../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: (args: { + sessionId: string + limit: number + beforeOffset?: number + }) => + readOpenCodeNativeChatTranscriptTail(args, { + resolveDbPath: async () => state.path, + readPage: async (page) => readOpenCodeTranscriptPage(page) + }), + subscribeNativeChatTranscript: async (args: SubscribeNativeChatTranscriptArgs) => { + state.watcher = args + return { watching: true, unsubscribe: vi.fn() } + } +})) +import { NATIVE_CHAT_METHODS } from './native-chat' + +const roots: string[] = [] +afterEach(() => { + state.watcher = null + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-opencode-paired-pages-')) + roots.push(root) + state.path = join(root, 'opencode.db') + const db = new Database(state.path) + db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT, session_id TEXT, type TEXT, seq INTEGER, + data TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session_v2 VALUES ('session');`) + for (let index = 1; index <= 3; index++) { + db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)').run( + String(index), + 'session', + 'assistant', + index * 7, + JSON.stringify({ + content: [ + { type: 'reasoning', text: `thinking ${index}` }, + { type: 'text', text: `answer ${index}` } + ] + }), + 1, + 1 + ) + } + db.close() +} + +function context(): RpcContext { + const runtime = { registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These handlers only register or invoke subscription cleanup on the runtime. + return { runtime: runtime as unknown as RpcContext['runtime'], clientKind: 'mobile' } +} + +describe.each(['opencode', 'opencode2'])('%s paired semantic pages', (agent) => { + it('reconstructs reasoning and answers across raw-row cursors at limit 1', async () => { + fixture() + const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.readSession')! + if (isStreamingMethod(method)) { + throw new Error('Expected a page read') + } + const pages: string[][] = [] + let beforeOffset: number | undefined + for (let index = 0; index < 4; index++) { + const params = method.params?.parse({ agent, sessionId: 'session', limit: 1, beforeOffset }) + const result = asRecord(await method.handler(params, context())) + expect(result?.hasMore).toBe(index < 2) + const messages = Array.isArray(result?.messages) ? result.messages : [] + expect(messages.map((message) => asRecord(message)?.role)).toEqual(['reasoning', 'assistant']) + pages.push(messages.map((message) => String(asRecord(message)?.id))) + if (result?.hasMore !== true) { + break + } + expect(typeof result.beforeOffset).toBe('number') + beforeOffset = typeof result.beforeOffset === 'number' ? result.beforeOffset : undefined + } + expect(pages.toReversed().flat()).toEqual([ + 'opencode:1:reasoning', + 'opencode:1', + 'opencode:2:reasoning', + 'opencode:2', + 'opencode:3:reasoning', + 'opencode:3' + ]) + }) + + it('keeps the pair in snapshot and replacement frames while sanitizing mobile text', async () => { + fixture() + const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.subscribe')! + if (!isStreamingMethod(method)) { + throw new Error('Expected a subscription') + } + const frames: unknown[] = [] + await method.handler( + method.params?.parse({ agent, sessionId: 'session', limit: 1 }), + context(), + (frame) => frames.push(frame) + ) + const page = readOpenCodeTranscriptPage({ dbPath: state.path, sessionId: 'session', limit: 1 })! + const messages = page.items.map((item) => item.message) + messages[0].blocks = [{ type: 'text', text: 'x'.repeat(100_000) }] + const watcher = state.watcher + if (!watcher) { + throw new Error('Expected a native watcher') + } + watcher.onInitialSnapshot?.(messages, page.hasMore, page.beforeMessageRowId ?? 0) + watcher.onReplace?.(messages, page.hasMore, page.beforeMessageRowId ?? 0) + expect(frames).toHaveLength(2) + for (const frame of frames) { + const record = asRecord(frame) + const published = Array.isArray(record?.messages) ? record.messages : [] + expect(published.map((message) => asRecord(message)?.role)).toEqual([ + 'reasoning', + 'assistant' + ]) + expect(record?.beforeOffset).toBe(page.beforeMessageRowId) + expect(JSON.stringify(published).length).toBeLessThan(100_000) + } + expect(messages[0].blocks[0]).toEqual({ type: 'text', text: 'x'.repeat(100_000) }) + }) +}) diff --git a/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts b/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts new file mode 100644 index 00000000000..ec9d872d5d3 --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts @@ -0,0 +1,333 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHash } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Database from '../../../sqlite/sync-database' +import { asRecord } from '../../../ai-vault/session-scanner-values' +import { readOpenCodeNativeChatTranscriptTail } from '../../../native-chat/transcript-opencode' +import { readOpenCodeTranscriptPage } from '../../../native-chat/transcript-opencode-sqlite-query' +import type { SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch-contract' +import { createDispatcherStreamingFeatureEmitter } from '../dispatcher-streaming-feature-emitter' +import { mobileE2EETextPayloadAdmissionBytes } from '../mobile-e2ee-outbound-admission' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../../shared/remote-runtime-memory-limits' +import { buildRegistry, isStreamingMethod, type RpcContext } from '../core' + +const state: { path: string; watcher: SubscribeNativeChatTranscriptArgs | null } = vi.hoisted( + () => ({ path: '', watcher: null }) +) +vi.mock('../../../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: (args: { + sessionId: string + limit: number + beforeOffset?: number + }) => + readOpenCodeNativeChatTranscriptTail(args, { + resolveDbPath: async () => state.path, + readPage: async (page) => readOpenCodeTranscriptPage(page) + }), + subscribeNativeChatTranscript: async (args: SubscribeNativeChatTranscriptArgs) => { + state.watcher = args + return { watching: true, unsubscribe: vi.fn() } + } +})) +import { NATIVE_CHAT_METHODS } from './native-chat' +import { + boundNativeChatRpcPageByBytes, + nativeChatRpcAppendBatches +} from './native-chat-rpc-page-bounds' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +const roots: string[] = [] +afterEach(() => { + state.watcher = null + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function denseFixture(version: 'v1' | 'v2'): string { + const root = mkdtempSync(join(tmpdir(), 'orca-opencode-wire-budget-')) + roots.push(root) + const path = join(root, 'opencode.db') + state.path = path + const db = new Database(path) + if (version === 'v2') { + db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT, session_id TEXT, type TEXT, seq INTEGER, + data TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session_v2 VALUES ('session');`) + } else { + db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY); + CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT); + CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_updated INTEGER, data TEXT); + INSERT INTO session VALUES ('session');`) + } + db.exec('BEGIN') + const insert = (id: string, role: 'user' | 'assistant', cursor: number, content: unknown[]) => { + if (version === 'v2') { + db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)').run( + id, + 'session', + role, + cursor, + JSON.stringify({ content }), + cursor, + cursor + ) + } else { + db.prepare('INSERT INTO message VALUES (?, ?, ?, ?, ?)').run( + id, + 'session', + cursor, + cursor, + JSON.stringify({ role }) + ) + for (const [index, part] of content.entries()) { + db.prepare('INSERT INTO part VALUES (?, ?, ?, ?, ?)').run( + `${id}:${index}`, + id, + 'session', + cursor, + JSON.stringify(part) + ) + } + } + } + insert('oldest', 'user', 1, [{ type: 'text', text: 'Earlier history' }]) + for (let index = 1; index <= 1200; index++) { + insert(String(index), 'assistant', index * 7, [ + { type: 'reasoning', text: 'Я'.repeat(900) }, + { type: 'text', text: 'Я'.repeat(900) } + ]) + } + insert('latest', 'user', 1201 * 7, [{ type: 'text', text: 'Я'.repeat(900) }]) + db.exec('COMMIT') + db.close() + return path +} + +function context(clientKind: RpcContext['clientKind'] = 'mobile'): RpcContext { + const runtime = { registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Native-chat only uses these subscription cleanup methods; its feature emitter records no interaction. + return { runtime: runtime as unknown as RpcContext['runtime'], clientKind } +} + +describe.each(['v1', 'v2'] as const)('%s byte-bounded semantic pages', (version) => { + it.each(['mobile', 'runtime'] as const)( + 'keeps an actual serialized %s replacement within E2EE admission and pages all groups', + async (clientKind) => { + const dbPath = denseFixture(version) + const page = readOpenCodeTranscriptPage({ dbPath, sessionId: 'session', limit: 2400 })! + expect(page.items).toHaveLength(2401) + expect(page.items.every((item) => Number.isFinite(item.message.transcriptOffset))).toBe(true) + expect(page.items.every((item) => item.message.transcriptOffset === item.rowid)).toBe(true) + const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.subscribe')! + if (!isStreamingMethod(method)) { + throw new Error('Expected a subscription') + } + const ctx = context(clientKind) + const replies: string[] = [] + const request = { id: 'budget-control', authToken: 'fixture', method: 'nativeChat.subscribe' } + const emitter = createDispatcherStreamingFeatureEmitter( + ctx.runtime, + request, + { runtimeId: 'private-budget-control' }, + (reply) => replies.push(reply) + ) + await method.handler( + method.params?.parse({ agent: 'opencode2', sessionId: 'session', limit: 2000 }), + ctx, + emitter.emit + ) + const watcher = state.watcher + if (!watcher) { + throw new Error('Expected a native watcher') + } + watcher.onReplace?.( + page.items.map((item) => item.message), + page.hasMore, + page.beforeMessageRowId ?? 0 + ) + const reply = replies[0] + const admission = mobileE2EETextPayloadAdmissionBytes(reply) + const response = asRecord(JSON.parse(reply)) + const result = asRecord(response?.result) + const messages = Array.isArray(result?.messages) ? result.messages : [] + const receipt = process.env.ORCA_NATIVE_CHAT_BUDGET_RECEIPT + if (receipt && version === 'v2' && clientKind === 'mobile') { + writeFileSync(`${receipt}.payload.json`, reply) + writeFileSync( + receipt, + `${JSON.stringify( + { + source: process.env.ORCA_NATIVE_CHAT_BUDGET_SOURCE, + kind: 'Controlled synthetic SQLite stress; actual production parser/RPC serializer/E2EE admission, no genuine CLI claim', + inputMessages: page.items.length, + outputMessages: messages.length, + serializedBytes: Buffer.byteLength(reply), + sha256: createHash('sha256').update(reply).digest('hex'), + maximumBytes: REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, + admission: Number.isFinite(admission) ? admission : 'Infinity', + hasMore: result?.hasMore, + beforeOffset: result?.beforeOffset, + first: messages[0], + last: messages.at(-1) + }, + null, + 2 + )}\n` + ) + } + expect(Number.isFinite(admission)).toBe(true) + expect(messages.length).toBeLessThan(page.items.length) + expect(result?.hasMore).toBe(true) + const first = asRecord(messages[0]) + expect(result?.beforeOffset).toBe(first?.transcriptOffset) + expect(messages.map((message) => asRecord(message)?.role).at(0)).toBe('reasoning') + expect(messages.map((message) => asRecord(message)?.role).at(1)).toBe('assistant') + expect(new Set(messages.map((message) => asRecord(message)?.id)).size).toBe(messages.length) + const read = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.readSession')! + if (isStreamingMethod(read)) { + throw new Error('Expected a page read') + } + const pages = [messages] + let cursor = typeof result?.beforeOffset === 'number' ? result.beforeOffset : undefined + let hasMore = result?.hasMore === true + while (hasMore) { + const earlier = asRecord( + await read.handler( + read.params?.parse({ + agent: 'opencode', + sessionId: 'session', + limit: 2000, + beforeOffset: cursor + }), + ctx + ) + ) + const entries = Array.isArray(earlier?.messages) ? earlier.messages : [] + expect(entries.length).toBeGreaterThan(0) + const before = typeof earlier?.beforeOffset === 'number' ? earlier.beforeOffset : undefined + expect(before).toBeLessThan(cursor!) + cursor = before + hasMore = earlier?.hasMore === true + pages.unshift(entries) + } + const all = pages.flat() + const ids = all.map((message) => asRecord(message)?.id) + expect(all).toHaveLength(2402) + expect(new Set(ids).size).toBe(2402) + const prefix = version === 'v2' ? 'opencode:' : '' + expect(ids).toEqual([ + `${prefix}oldest`, + ...Array.from({ length: 1200 }, (_, index) => [ + `${prefix}${index + 1}:reasoning`, + `${prefix}${index + 1}` + ]).flat(), + `${prefix}latest` + ]) + } + ) +}) + +function message(id: string, offset: number, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + source: 'transcript', + timestamp: 10, + transcriptOffset: offset, + blocks: [{ type: 'text', text }] + } +} + +it('replaces an unsendable newest raw group with one stable localized notice', () => { + const large = [ + message('row:reasoning', 42, 'Я'.repeat(1_000_000)), + message('row', 42, 'Я'.repeat(1_000_000)) + ] + large[0].role = 'reasoning' + const page = boundNativeChatRpcPageByBytes([message('older', 2, 'older'), ...large], false, 2) + expect(page).toEqual({ + messages: [ + { + id: 'row', + role: 'system', + source: 'transcript', + timestamp: 10, + transcriptOffset: 42, + blocks: [ + { + type: 'text', + text: 'This part of the chat was too large to show.', + presentation: 'history-item-too-large' + } + ] + } + ], + hasMore: true, + beforeOffset: 42 + }) + expect(Number.isFinite(mobileE2EETextPayloadAdmissionBytes(JSON.stringify(page)))).toBe(true) + const only = boundNativeChatRpcPageByBytes(large, false, 42) + expect(only.hasMore).toBe(false) + expect(only.beforeOffset).toBe(42) +}) + +it('keeps append batches complete and admits every serialized batch', () => { + const messages = Array.from({ length: 12 }, (_, index) => [ + message(`${index}:reasoning`, index, 'Я'.repeat(100_000)), + message(String(index), index, 'Я'.repeat(100_000)) + ]).flat() + for (let index = 0; index < messages.length; index += 2) { + messages[index].role = 'reasoning' + } + const batches = nativeChatRpcAppendBatches(messages) + expect(batches.length).toBeGreaterThan(1) + expect(batches.flat()).toEqual(messages) + for (const batch of batches) { + expect(batch[0].role).toBe('reasoning') + expect(batch.at(-1)?.role).toBe('assistant') + expect( + Number.isFinite( + mobileE2EETextPayloadAdmissionBytes(JSON.stringify({ type: 'appended', messages: batch })) + ) + ).toBe(true) + } +}) + +it('does not manufacture a paging cursor for legacy messages without optional offsets', () => { + const messages = [ + message('old', 1, 'Я'.repeat(700_000)), + message('new', 2, 'Я'.repeat(700_000)) + ].map(({ transcriptOffset: _offset, ...row }) => row) + const result = boundNativeChatRpcPageByBytes(messages, true, 90) + expect(result.hasMore).toBe(true) + expect(result.beforeOffset).toBe(90) + expect(result.messages).toHaveLength(1) + expect(result.messages[0].role).toBe('system') + expect(result.messages[0].blocks[0]).toMatchObject({ presentation: 'history-item-too-large' }) +}) + +it('preserves empty append lifecycle frames', async () => { + const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.subscribe')! + if (!isStreamingMethod(method)) { + throw new Error('Expected a subscription') + } + const frames: unknown[] = [] + await method.handler( + method.params?.parse({ agent: 'opencode2', sessionId: 'session' }), + context(), + (frame) => frames.push(frame) + ) + state.watcher?.onAppend([], { state: 'completed', turnId: 'turn', timestamp: 10 }) + expect(frames).toEqual([ + { + type: 'appended', + messages: [], + lifecycle: { state: 'completed', turnId: 'turn', timestamp: 10 } + } + ]) +}) diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts b/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts new file mode 100644 index 00000000000..1ebdf1056dc --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts @@ -0,0 +1,100 @@ +import { + nativeChatMessagesShareTranscriptRow, + nativeChatSemanticRowId, + type NativeChatMessage +} from '../../../../shared/native-chat-types' +import { agentSessionHostStatusBody } from '../../../../shared/agent-session-host-status-rows' +import { boundJournalKeyComponent } from '../../../../shared/agent-session-journal-item-key' +import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from '../../../native-chat/agent-session-wire/agent-session-history-page-bounds' + +function oversizedRow(message: NativeChatMessage): NativeChatMessage { + const { text, presentation } = agentSessionHostStatusBody('history-item-too-large') + return { + id: boundJournalKeyComponent(nativeChatSemanticRowId(message)), + role: 'system', + blocks: [{ type: 'text', text, presentation }], + timestamp: message.timestamp, + source: message.source, + ...(message.transcriptOffset !== undefined + ? { transcriptOffset: message.transcriptOffset } + : {}) + } +} + +function groupBytes(group: readonly NativeChatMessage[]): number { + return group.reduce((bytes, message) => bytes + Buffer.byteLength(JSON.stringify(message)) + 1, 0) +} + +function rowGroups(messages: readonly NativeChatMessage[]): NativeChatMessage[][] { + const groups: NativeChatMessage[][] = [] + for (const message of messages) { + const last = groups.at(-1) + if (last && nativeChatMessagesShareTranscriptRow(last.at(-1)!, message)) { + last.push(message) + } else { + groups.push([message]) + } + } + return groups +} + +export function boundNativeChatRpcPageByBytes( + messages: readonly NativeChatMessage[], + hasMore: boolean, + beforeOffset: number +): { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } { + const groups = rowGroups(messages) + const kept: NativeChatMessage[][] = [] + let bytes = 2 + let retained = 0 + for (const group of groups.toReversed()) { + const size = groupBytes(group) + if (kept.length === 0 && size > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + kept.push([oversizedRow(group[0])]) + retained += group.length + break + } + if (bytes + size > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + break + } + kept.push(group) + retained += group.length + bytes += size + } + const selected = kept.toReversed().flat() + const dropped = retained < messages.length + const cursor = selected[0]?.transcriptOffset + // A legacy projection has no row cursor: explicitly omit the page rather than skip unseen history. + if (dropped && cursor === undefined) { + return { messages: messages.length ? [oversizedRow(messages[0])] : [], hasMore, beforeOffset } + } + return { + messages: selected, + hasMore: hasMore || dropped, + beforeOffset: dropped ? (cursor ?? beforeOffset) : beforeOffset + } +} + +export function nativeChatRpcAppendBatches( + messages: readonly NativeChatMessage[] +): NativeChatMessage[][] { + const batches: NativeChatMessage[][] = [] + let batch: NativeChatMessage[] = [] + let bytes = 2 + for (const group of rowGroups(messages)) { + const bounded = + groupBytes(group) > HISTORY_PAGE_CONTENT_BUDGET_BYTES ? [oversizedRow(group[0])] : group + const size = groupBytes(bounded) + if (batch.length > 0 && bytes + size > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + batches.push(batch) + batch = [] + bytes = 2 + } + batch.push(...bounded) + bytes += size + } + if (batch.length > 0) { + batches.push(batch) + } + return batches +} diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 05f8f7f8726..99c0178ea0c 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,4 +1,5 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../../../shared/native-chat-agent-support' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript, @@ -7,6 +8,10 @@ import { } from '../../../native-chat/transcript-watch' import { defineMethod, defineStreamingMethod, type RpcContext } from '../core' import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize' +import { + boundNativeChatRpcPageByBytes, + nativeChatRpcAppendBatches +} from './native-chat-rpc-page-bounds' import { MOBILE_NATIVE_CHAT_MAX_WINDOW, NativeChatSession, @@ -51,16 +56,21 @@ function windowTranscript( return messages.length > window ? messages.slice(-window) : messages.slice() } -/** Apply the windowed slice and keep inline image bytes off every RPC transport. - * Mobile clients additionally receive bounded text and tool bodies; runtime - * clients keep those bodies intact. */ -function windowForClient( +function pageForClient( messages: readonly NativeChatMessage[], + hasMore: boolean, + beforeOffset: number, clientKind: RpcContext['clientKind'], - limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW -): NativeChatMessage[] { - const windowed = windowTranscript(messages, limit) - return windowed.map((message) => sanitizeMessage(message, clientKind)) + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW, + agent?: string +): { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } { + const isOpenCode = resolveNativeChatTranscriptAgent(agent) === 'opencode' + const sanitized = (isOpenCode ? messages : windowTranscript(messages, limit)).map((message) => + sanitizeMessage(message, clientKind) + ) + return isOpenCode + ? boundNativeChatRpcPageByBytes(sanitized, hasMore, beforeOffset) + : { messages: sanitized, hasMore, beforeOffset } } export const NATIVE_CHAT_METHODS = [ @@ -81,9 +91,14 @@ export const NATIVE_CHAT_METHODS = [ ) return 'messages' in result ? { - messages: windowForClient(result.messages, clientKind, limit), - hasMore: result.hasMore, - beforeOffset: result.beforeOffset, + ...pageForClient( + result.messages, + result.hasMore, + result.beforeOffset, + clientKind, + limit, + params.agent + ), ...(result.lifecycle ? { lifecycle: result.lifecycle } : {}) } : result @@ -144,9 +159,7 @@ export const NATIVE_CHAT_METHODS = [ // instead of stranding the view at 'loading' when the read keeps throwing. emit({ type: 'snapshot', - messages: windowForClient(messages, clientKind, limit), - hasMore, - beforeOffset, + ...pageForClient(messages, hasMore, beforeOffset, clientKind, limit, params.agent), ...(error ? { error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -166,9 +179,7 @@ export const NATIVE_CHAT_METHODS = [ } emit({ type: 'replacement', - messages: windowForClient(messages, clientKind, limit), - hasMore, - beforeOffset, + ...pageForClient(messages, hasMore, beforeOffset, clientKind, limit, params.agent), ...(lifecycle ? { lifecycle } : {}) }) }, @@ -176,11 +187,18 @@ export const NATIVE_CHAT_METHODS = [ if (closed) { return } - emit({ - type: 'appended', - messages: sanitizeAppendForClient(messages, clientKind), - ...(lifecycle ? { lifecycle } : {}) - }) + const sanitized = sanitizeAppendForClient(messages, clientKind) + const batches = + sanitized.length > 0 && resolveNativeChatTranscriptAgent(params.agent) === 'opencode' + ? nativeChatRpcAppendBatches(sanitized) + : [sanitized] + for (const batch of batches) { + emit({ + type: 'appended', + messages: batch, + ...(lifecycle && batch === batches.at(-1) ? { lifecycle } : {}) + }) + } } } let subscription: NativeChatTranscriptSubscription diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts index 85e01116c3c..e143535d659 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -14,9 +14,7 @@ import { type TerminalOutputMeta } from './terminal-output-frame-chunks' -// Byte-for-byte reference: the pre-optimization implementation, copied verbatim. -// It accumulates `chunk += part` over `for (const part of data)` and measures each -// code point through the shared clipboard measurer. +// The legacy splitter, caching only its pure byte counts for repeated code points. function legacyByteLength(data: string): number { return measureClipboardTextByteLength(data).byteLength } @@ -64,6 +62,7 @@ function* legacyIterateTerminalOutputFrameChunks( let chunkStartOffset = 0 let offset = 0 let delayedChunk: { text: string; seq?: number } | null = null + const partByteLengths = new Map() const takeChunk = (): { text: string; seq?: number } | null => { if (!chunk) { @@ -78,7 +77,11 @@ function* legacyIterateTerminalOutputFrameChunks( } for (const part of data) { - const partBytes = legacyByteLength(part) + let partBytes = partByteLengths.get(part) + if (partBytes === undefined) { + partBytes = legacyByteLength(part) + partByteLengths.set(part, partBytes) + } if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { const nextChunk = takeChunk() if (nextChunk) { @@ -522,34 +525,4 @@ describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization expect([...iterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) expect([...legacyIterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) }) - - // The chunking loop is only reached when rawLength === data.length and transformed - // is falsy, which makes canPreserveChunkSeq === (typeof meta.seq === 'number') and - // therefore shouldDelayFinalSeq unconditionally false. The branch survives here - // (it also survived in the pre-optimization code) purely as defence in depth. - it('never reaches the delayed-final-seq branch for any meta shape', () => { - for (const data of ['', 'a', 'abc', 'x'.repeat(200)]) { - for (const seq of [undefined, 0, 5] as (number | undefined)[]) { - for (const rawDelta of [undefined, 0, 1, -1] as (number | undefined)[]) { - for (const transformed of [undefined, false, true] as (boolean | undefined)[]) { - const meta: TerminalOutputMeta = {} - if (seq !== undefined) { - meta.seq = seq - } - if (rawDelta !== undefined) { - meta.rawLength = data.length + rawDelta - } - if (transformed !== undefined) { - meta.transformed = transformed - } - const rawLength = meta.rawLength ?? data.length - const reachesChunkLoop = !meta.transformed && rawLength === data.length - const canPreserveChunkSeq = typeof meta.seq === 'number' && rawLength === data.length - const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta.seq === 'number' - expect(reachesChunkLoop && shouldDelayFinalSeq, JSON.stringify(meta)).toBe(false) - } - } - } - } - }) }) diff --git a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts index 8ed435fddc7..83e30a15f47 100644 --- a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split class members. +import { RipgrepSearchDiagnostics } from '../../shared/ripgrep-search-diagnostics' import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' import { RuntimeFileCommandsWithSearchRuntimeFiles } from './runtime-file-commands-search-runtime-files' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' @@ -50,20 +51,26 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC return new Promise((resolvePromise, rejectPromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` - const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) + const rgArgs = buildRgArgs(options.query, '.', options) const previousChild = this.activeRuntimeTextSearches.get(searchKey) if (previousChild) { killSpawnedRipgrepProcess(previousChild) } const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false let child: ChildProcessHandle | null = null const transformAbsPath = wslDistroForOutput - ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) + ? (p: string): string | null => + p.includes('\\') + ? null + : p.startsWith('/') + ? toWindowsWslPath(p, wslDistroForOutput) + : p : undefined const finish = (result: SearchResult | PromiseLike): void => { @@ -77,7 +84,10 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC cleanupListeners() resolvePromise(result) } - const resolveOnce = (): void => finish(finalize(acc)) + const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => { + const error = diagnostics.failure(code, signal, acc) + finish(error ? Promise.reject(error) : finalize(acc)) + } const rejectUnavailable = (): void => finish(Promise.reject(bundledRipgrepUnavailableError())) let killTimeout: ReturnType | null = null @@ -133,10 +143,16 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC nextChild.stdout?.setEncoding('utf-8') const onStdoutData = (chunk: string): void => { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + } } - const onStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. + const onStderrData = (chunk: Buffer): void => { + diagnostics.append(chunk) } const onError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true @@ -171,7 +187,10 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC }) return } - resolveOnce() + finish(Promise.reject(error)) + if (child) { + killSpawnedRipgrepProcess(child) + } } const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise @@ -190,11 +209,11 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC rejectUnavailable() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } nextChild.stdout?.on('data', onStdoutData) @@ -229,7 +248,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC worktree: target.worktree, path: joinWorktreeRelativePath( target.worktree.path, - normalizeRuntimeRelativePath(relativePath) + normalizeRuntimeRelativePath(relativePath, target.worktree.path) ), executionHostId: target.executionHostId })) diff --git a/src/main/runtime/runtime-github-issue-comment-commands.ts b/src/main/runtime/runtime-github-issue-comment-commands.ts index fcef9602405..e3f19c36d76 100644 --- a/src/main/runtime/runtime-github-issue-comment-commands.ts +++ b/src/main/runtime/runtime-github-issue-comment-commands.ts @@ -50,7 +50,8 @@ export class RuntimeGitHubIssueCommentCommands { number, updates, repo.connectionId ?? null, - ...this.deps.getLocalGitArgs(repo) + this.deps.getLocalGitArgs(repo)[0], + repo.issueSourcePreference ) } @@ -58,7 +59,8 @@ export class RuntimeGitHubIssueCommentCommands { repoSelector: string, number: number, body: string, - prRepo?: GitHubOwnerRepo | null + prRepo?: GitHubOwnerRepo | null, + type?: 'issue' | 'pr' ): Promise>> { const repo = await this.deps.resolveRepo(repoSelector) return addIssueComment( @@ -67,7 +69,9 @@ export class RuntimeGitHubIssueCommentCommands { body, repo.connectionId ?? null, prRepo ?? null, - ...this.deps.getLocalGitArgs(repo) + this.deps.getLocalGitArgs(repo)[0], + // Why: the issue source selector only scopes issues; PR comments keep their resolution. + type === 'pr' ? undefined : repo.issueSourcePreference ) } diff --git a/src/main/runtime/runtime-relative-paths.ts b/src/main/runtime/runtime-relative-paths.ts index 1ec252e24f2..1f5ab8e71cb 100644 --- a/src/main/runtime/runtime-relative-paths.ts +++ b/src/main/runtime/runtime-relative-paths.ts @@ -2,15 +2,21 @@ import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' export function joinWorktreeRelativePath(rootPath: string, relativePath: string): string { - const normalizedRelativePath = relativePath.replace(/\\/g, '/') + const normalizedRelativePath = isWindowsAbsolutePathLike(rootPath) + ? relativePath.replace(/\\/g, '/') + : relativePath if (isWindowsAbsolutePathLike(rootPath)) { return win32.join(rootPath.replace(/\//g, '\\'), ...normalizedRelativePath.split('/')) } return posix.join(rootPath, ...normalizedRelativePath.split('/')) } -export function normalizeRuntimeRelativePath(relativePath: string): string { - const normalized = relativePath.replace(/\\/g, '/').replace(/\/+$/, '') +export function normalizeRuntimeRelativePath(relativePath: string, rootPath?: string): string { + const path = + rootPath !== undefined && !isWindowsAbsolutePathLike(rootPath) + ? relativePath + : relativePath.replace(/\\/g, '/') + const normalized = path.replace(/\/+$/, '') if (normalized === '') { return '' } diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 93e28cf7518..78e0f475e21 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -7,6 +7,7 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges import type { TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' import type { TerminalTitleTracker } from '../../shared/terminal-output-side-effects' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalAgent } from '../../shared/terminal-agent' import type { HeadlessEmulator } from '../daemon/headless-emulator' import type { PtyProviderBufferSnapshot } from '../providers/types' import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' @@ -66,7 +67,7 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { launchIncarnationId: PtyIncarnationId | null launchAgent: TuiAgent | null agentSessionOwners: AgentSessionOwnerBinding[] - foregroundAgent: TuiAgent | null + foregroundAgent: TerminalAgent | null connected: boolean disconnectedAt: number | null lastExitCode: number | null diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 10e94f3747f..8c308d22b28 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -1,5 +1,5 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' -import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalAgent } from '../../shared/terminal-agent' import { iterateTerminalInputChunks } from '../../shared/terminal-input' import type { TerminalInputKind } from '../../shared/terminal-input-kind' @@ -17,7 +17,7 @@ export class RuntimeTerminalWriter { 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 + private readonly getAgent: (ptyId: string) => TerminalAgent | null = () => null ) {} async writeAction( diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 36ab233a915..57e632a6c85 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -35,11 +35,17 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' // The turns a send or Stop is waiting on to open, so a test knows the wait began. const openWaits = vi.hoisted(() => { - const turnIds: string[] = [] - return { turnIds } + const state: { + turnIds: string[] + ended: string[] + began: (() => void) | null + releaseAll: (() => void) | null + } = { turnIds: [], ended: [], began: null, releaseAll: null } + return state }) vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { const actual = await importOriginal() @@ -47,11 +53,16 @@ vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { ...actual, createCodexTurnOpenWaits: () => { const waits = actual.createCodexTurnOpenWaits() + openWaits.releaseAll = waits.releaseAll return { ...waits, wait: (turnId: string, withinMs: number) => { openWaits.turnIds.push(turnId) - return waits.wait(turnId, withinMs) + const pending = waits.wait(turnId, withinMs) + openWaits.began?.() + return pending.then(() => { + openWaits.ended.push(turnId) + }) } } } @@ -170,6 +181,8 @@ function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessage beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) openWaits.turnIds.length = 0 + openWaits.ended.length = 0 + openWaits.began = null answers = 0 steers = 0 interrupts = 0 @@ -239,6 +252,8 @@ beforeEach(async () => { }) afterEach(async () => { + openWaits.releaseAll?.() + vi.useRealTimers() await stopStructuredAgentSessionRuntime() await rm(root, { recursive: true, force: true }) }) @@ -341,7 +356,14 @@ describe('a queued card sent now into the turn a Stop ends', () => { ) // A drain ignoring the pause re-sends only after the stopped turn ends: watch past that. await vi.waitFor(() => expect(turns.turnId).toBeNull()) - await new Promise((resolve) => setTimeout(resolve, 2_500)) + const clock = createCoordinatorMailObservationClock(() => host, SESSION) + clock.start() + try { + await clock.observe(2_500) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + } finally { + clock.restore() + } expect(steers + answers).toBe(2) expect(await sends(cardId)).toEqual([{ origin: 'client', verdict: 'withdrawn' }]) }, 20_000) @@ -454,9 +476,17 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) + await host.flushStreamedEvents(SESSION) + const began = Promise.withResolvers() + openWaits.began = began.resolve + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const stopping = stop() + await began.promise // Without the wait, it would reach Codex now, which would refuse it, and be done. - expect(await settledWithin(stopping, 1_000)).toBe('held') + const held = settledWithin(stopping, 1_000) + await vi.advanceTimersByTimeAsync(1_000) + expect(openWaits.ended).toEqual([]) + expect(await held).toBe('held') expect(interrupts).toBe(0) turns.start() await stopping @@ -480,8 +510,15 @@ describe('a Stop in that window that the turn never opens for', () => { async function waitingStop(): Promise<{ stopping: Promise }> { await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) + await host.flushStreamedEvents(SESSION) + const began = Promise.withResolvers() + openWaits.began = began.resolve + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const stopping = stop() - expect(await settledWithin(stopping, 200)).toBe('held') + await began.promise + const held = settledWithin(stopping, 200) + await vi.advanceTimersByTimeAsync(200) + expect(await held).toBe('held') return { stopping } } @@ -499,10 +536,17 @@ describe('a Stop in that window that the turn never opens for', () => { const { stopping } = await waitingStop() const closing = host.close(SESSION, 'evict') + const closedWithinBound = settledWithin( + closing, + CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS + ) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) + expect(openWaits.ended).toEqual([]) + expect(childCloses).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(openWaits.ended).toEqual(['turn-1']) - expect( - await settledWithin(closing, CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) - ).not.toBe('held') + expect(await closedWithinBound).not.toBe('held') expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) expect(interrupts).toBe(0) @@ -512,9 +556,16 @@ describe('a Stop in that window that the turn never opens for', () => { it('lets the app quit behind it within the eviction budget, and still close the child', async () => { await waitingStop() - expect( - await settledWithin(stopStructuredAgentSessionRuntime(), CHILD_EVICTION_TIMEOUT_MS) - ).not.toBe('held') + const quitWithinBound = settledWithin( + stopStructuredAgentSessionRuntime(), + CHILD_EVICTION_TIMEOUT_MS + ) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) + expect(openWaits.ended).toEqual([]) + expect(childCloses).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(openWaits.ended).toEqual(['turn-1']) + expect(await quitWithinBound).not.toBe('held') expect(childCloses).toBe(1) }) }) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 1289e979ad6..095aad2a8a5 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -70,6 +70,7 @@ describe('structuredSessionChildIdentityEnv', () => { // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), + ORCA_CLI_BIN_DIR: SHIM_DIR, // The instance that minted the id, so any current CLI dials it rather than the default. ORCA_USER_DATA_PATH: USER_DATA }) @@ -131,6 +132,7 @@ describe('structuredSessionChildIdentityEnv', () => { expect(env.PATH).toBeUndefined() // The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle. expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca.exe')) + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() }) it('unpackaged, through the dev launcher dir', () => { diff --git a/src/main/runtime/terminal-projection.test.ts b/src/main/runtime/terminal-projection.test.ts index 7dff087e74c..c1634ddb19c 100644 --- a/src/main/runtime/terminal-projection.test.ts +++ b/src/main/runtime/terminal-projection.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' import type { HeadlessEmulator } from '../daemon/headless-emulator' -import { projectTerminalTailLines } from './orca-runtime' +import { projectTerminalTailLines } from './orca-runtime-terminal-projection' describe('projectTerminalTailLines', () => { it('does not splice a scrolled viewport into unrelated tail rows', () => { diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts index 85460b9fc27..1966a405949 100644 --- a/src/main/runtime/terminal-run-facts.test.ts +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest' import { TerminalRunFactsRegister } from './terminal-run-facts' describe('terminal run facts', () => { + it('keeps driving input before publication across the exact reserved commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pending', 'driving', 'x', 100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + expect(facts.read('pending', 'inc-1').firstUserInputAt).toBe(100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + facts.recordInput('pending', 'driving', 'x', 200) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + expect(facts.read('pending', 'inc-2').firstUserInputAt).toBe(200) + facts.delete('pending') + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + expect(facts.read('pending', 'inc-3').firstUserInputAt).toBeNull() + }) it('reads a run main never saw committed as not fresh', () => { expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts index b20f64985e4..b1c029a767c 100644 --- a/src/main/runtime/terminal-run-facts.ts +++ b/src/main/runtime/terminal-run-facts.ts @@ -42,12 +42,38 @@ export class TerminalRunFactsRegister { private readonly runsByPtyId = new Map() // Why apart from the run record: input must count on a PTY main adopted without a commit. private readonly lastInputAtByPtyId = new Map() + private readonly pendingByPtyId = new Map< + string, + { incarnationId: string | null; firstInputAt: number | null } + >() + + reserveSpawnCommit(commit: TerminalSpawnCommit): void { + if (!commit.incarnationId) { + return + } + const prior = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.set(commit.id, { + incarnationId: commit.incarnationId, + firstInputAt: + prior?.incarnationId === null || prior?.incarnationId === commit.incarnationId + ? prior.firstInputAt + : null + }) + } + + discardSpawnCommit(commit: TerminalSpawnCommit): void { + if (this.pendingByPtyId.get(commit.id)?.incarnationId === (commit.incarnationId ?? null)) { + this.pendingByPtyId.delete(commit.id) + } + } /** Once per process: a re-registration of the same incarnation keeps its facts, and so does a * reattach or adoption of the running process unless its incarnation shows another process. */ recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { const incarnationId = commit.incarnationId ?? null const previous = this.runsByPtyId.get(commit.id) + const pending = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.delete(commit.id) if (incarnationId !== null && previous?.incarnationId === incarnationId) { return } @@ -60,7 +86,11 @@ export class TerminalRunFactsRegister { this.runsByPtyId.set(commit.id, { incarnationId, spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, - firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null + firstUserInputAt: sameProcess + ? (previous?.firstUserInputAt ?? null) + : pending?.incarnationId === incarnationId + ? pending.firstInputAt + : null }) } @@ -73,6 +103,14 @@ export class TerminalRunFactsRegister { } this.lastInputAtByPtyId.set(ptyId, now) const run = this.runsByPtyId.get(ptyId) + if (inputKind === 'driving') { + const pending = this.pendingByPtyId.get(ptyId) + if (pending) { + pending.firstInputAt ??= now + } else if (!run) { + this.pendingByPtyId.set(ptyId, { incarnationId: null, firstInputAt: now }) + } + } if (run && inputKind === 'driving') { run.firstUserInputAt ??= now } @@ -99,5 +137,6 @@ export class TerminalRunFactsRegister { delete(ptyId: string): void { this.runsByPtyId.delete(ptyId) this.lastInputAtByPtyId.delete(ptyId) + this.pendingByPtyId.delete(ptyId) } } diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index c0016b38de7..6d3ca50b4f9 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -11,6 +11,7 @@ import { import { evaluateTuiIdle, hasFreshDoneFirstPartyStatus, + quietForegroundLaneForTerminalAgent, hasQuietReadyScreen, isTuiIdleReadyVerdict, nameOnlyIdleNeedsCorroboration, @@ -178,6 +179,11 @@ describe('evaluateTuiIdle ranking', () => { // Why: a launched agent whose title Orca cannot classify has no other lane; closing this // one for every known agent left `worker start` failing at agent_readiness (STA-7440). + it('leaves the lane open for recognized dsb instead of reading a missing launch config', () => { + expect(quietForegroundLaneForTerminalAgent('dsb')).toBe('open') + expect(quietForegroundLaneForTerminalAgent('codex')).toBe('closed') + }) + it('keeps the quiet-foreground lane for an agent with no other rest signal, after it paints', () => { for (const agent of ['amp', 'goose', 'crush', 'kimi', 'qwen-code', 'rovo', 'aug'] as const) { expect(evaluateTuiIdle(input({ ...noMuse, agent }))).toEqual({ @@ -255,6 +261,10 @@ describe('rest signal agrees with the lanes that can settle a wait', () => { }) describe('nameOnlyIdleNeedsCorroboration', () => { + it('keeps recognition-only DSB titles outside managed idle-title policies', () => { + expect(nameOnlyIdleNeedsCorroboration(null, 'DeepSeek Build')).toBe(false) + }) + it('holds agents that announce rest with an explicit title, native or synthesized', () => { expect(nameOnlyIdleNeedsCorroboration('claude')).toBe(true) expect(nameOnlyIdleNeedsCorroboration('codex')).toBe(true) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 68f472a6339..c7cc3eb0852 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -8,6 +8,8 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-type import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title' import { resolveExplicitTerminalTitleAgentType } from '../../shared/terminal-title-agent-type' import type { TuiAgent } from '../../shared/tui-agent' +import type { TerminalAgent } from '../../shared/terminal-agent' +import { isTuiAgent } from '../../shared/tui-agent-config' import { getTuiAgentRestSignal } from '../../shared/tui-agent-rest-signal' import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' import { isOmpIdleStateTitle } from './omp-terminal-readiness' @@ -134,7 +136,7 @@ export function nameOnlyIdleNeedsCorroboration( // Why the title fallback: an adopted pane carries no launch metadata, but its // name-only title is exactly the thing that names the agent. const resolved = agent ?? (title ? resolveExplicitTerminalTitleAgentType(title) : null) - if (resolved === null) { + if (!isTuiAgent(resolved)) { return false } return ( @@ -179,8 +181,10 @@ function hasQuietOutput(record: TuiIdleEvidenceRecord, quiescenceMs: number): bo */ export type QuietForegroundLane = 'closed' | 'after-paint' | 'open' -function quietForegroundLane(agent: TuiAgent | null | undefined): QuietForegroundLane { - if (!agent) { +export function quietForegroundLaneForTerminalAgent( + agent: TerminalAgent | null | undefined +): QuietForegroundLane { + if (!isTuiAgent(agent)) { return 'open' } return getTuiAgentRestSignal(agent) === 'none' ? 'after-paint' : 'closed' @@ -343,7 +347,9 @@ function rankTuiIdleEvidence(input: TuiIdleEvaluationInput): TuiIdleVerdict { return { kind: 'pending', quietForeground: - input.record.lastAgentStatus === null ? quietForegroundLane(input.agent) : 'closed' + input.record.lastAgentStatus === null + ? quietForegroundLaneForTerminalAgent(input.agent) + : 'closed' } } diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index e21269bfa91..0001a98641e 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -17,6 +17,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' import { selectShellStartupFeatures } from './shell-startup-features' +import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' import { runZshPty } from './zsh-startup-hook-pty-harness' import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' import { @@ -119,6 +120,46 @@ describePosix('zsh launch config', () => { rmSync(userDataPath, { recursive: true, force: true }) }) + itWithZsh.each([false, true])( + 'restores this app CLI after zsh startup resets PATH and replaces prompt hooks %s', + async (replacePromptHooks) => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + writeFileSync( + join(userDataPath, '.zshrc'), + `export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${replacePromptHooks ? 'precmd_functions=()\n' : ''}` + ) + const env: Record = { + ...process.env, + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin` + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath }) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + ...PLAIN_PANE, + hasStartupCommand: true + }) + const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellLaunchConfig(ZSH_PATH, features) + const result = await runZshPty({ + env: { ...env, ...config.env }, + commands: ['ORCA_LOOKUP=$(command -v orca-dev)'], + report: ['ORCA_LOOKUP'] + }) + expect(result.values.ORCA_LOOKUP).toBe(launcher) + } + ) + it('publishes exactly the selected features, whatever process.env holds', async () => { process.env.ORCA_SHELL_FEATURES = 'overlay,markers,ready,identity' const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index cb7c2fca2e9..7b5f922e89f 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -102,8 +102,11 @@ export const ZSH_USER_ZSHENV_SOURCE_BLOCK = `{ [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi }` // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. diff --git a/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts b/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts new file mode 100644 index 00000000000..d2e5da79387 --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts @@ -0,0 +1,148 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import type { ClientChannel } from 'ssh2' +import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-exec-command' + +export const SSH_EXEC_OUTPUT_CAP_CHARS = 1024 * 1024 + +class RelayGcShellChannel extends PassThrough implements ClientChannel { + stdin: this = this + stdout: this = this + stderr: ReturnType['stderr'] + server = false as const + type = 'session' as const + subtype = 'exec' as const + incoming: unknown = null + outgoing: unknown = null + private readonly exited = Promise.withResolvers() + + constructor(private readonly child: ReturnType) { + super({ autoDestroy: false, emitClose: false }) + this.stderr = child.stderr + child.stdout.pipe(this) + child.stdout.on('error', (error) => this.emit('error', error)) + child.on('error', (error) => { + this.exited.resolve() + this.emit('error', error) + }) + child.on('close', (code) => { + this.exited.resolve() + this.emit('close', code) + }) + child.stdin.end() + } + + async dispose(): Promise { + this.close() + await this.exited.promise + this.destroy() + } + + close(): void { + this.child.kill() + } + + eof(): void { + this.end() + } + + setWindow(): void { + throw new Error('GC listing does not resize a terminal') + } + + signal(): void { + throw new Error('GC listing does not signal a terminal') + } + + exit(): void { + throw new Error('GC listing does not set a remote exit status') + } +} + +export async function runCappedRelayGcShellCommand(command: string): Promise { + let channel: RelayGcShellChannel | undefined + const connection: Pick = { + exec: async (shellCommand) => { + channel = new RelayGcShellChannel( + spawnProcess({ program: '/bin/sh', args: ['-c', shellCommand] }) + ) + return channel + }, + usesSystemSshTransport: () => false + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: execCommand only reads exec and usesSystemSshTransport; both methods are checked above. + const sshConnection = connection as SshConnection + try { + return await execCommand(sshConnection, command) + } finally { + await channel?.dispose() + } +} + +export function createRelayGcListingFixture(): { + root: string + findCommand: string + validNames: string[] + fillValidEntryBoundary: () => void + dispose: () => void +} { + const workspace = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) + let root = workspace + try { + // Long Unicode paths cross the SSH character cap with fewer real directories. + for (let index = 0; index < 4; index += 1) { + root = join(root, `gc-context-${index}-ü-${'x'.repeat(150)}`) + mkdirSync(root) + } + const validNames = ['relay-0.1.0+aaa', 'relay-0.1.0+bbb'] + const stageName = (index: number): string => + `relay-9.9.9+abc.upload-${String(index).padStart(12, '0')}` + const stageCount = + Math.ceil(SSH_EXEC_OUTPUT_CAP_CHARS / (join(root, stageName(0)).length + 1)) + 1 + for (let index = 0; index < stageCount; index += 1) { + mkdirSync(join(root, stageName(index))) + } + for (const name of validNames) { + mkdirSync(join(root, name)) + } + mkdirSync(join(root, 'relay-0.1.0+abc.upload-000000000000')) + mkdirSync(join(root, 'relay-0.1.0+ggg')) + mkdirSync(join(root, 'orcad-0.1.0+aaa')) + mkdirSync(join(root, stageName(0), 'relay-0.1.0+ccc')) + writeFileSync(join(root, 'relay-0.1.0+ddd'), '') + symlinkSync(join(root, validNames[0]), join(root, 'relay-0.1.0+eee'), 'dir') + return { + root, + findCommand: `find ${shellEscape(root)} -mindepth 1 -maxdepth 1 -type d -name 'relay-*' -print`, + validNames, + fillValidEntryBoundary: () => { + for (let index = 0; index < 63; index += 1) { + const name = `relay-0.1.0+${index.toString(16)}` + validNames.push(name) + mkdirSync(join(root, name)) + } + }, + dispose: () => rmSync(workspace, { recursive: true, force: true }) + } + } catch (error) { + rmSync(workspace, { recursive: true, force: true }) + throw error + } +} + +export async function readUncappedRelayGcPaths(command: string): Promise { + const result = await runProcess({ + program: '/bin/sh', + args: ['-c', command], + maxOutputBytes: 4 * SSH_EXEC_OUTPUT_CAP_CHARS + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error(`GC fixture enumeration failed: ${result.code}: ${result.stderr}`) + } + return result.stdout.trim().split('\n') +} diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 758d4739b0f..76073d24d89 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -11,7 +11,7 @@ import { utimesSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { describe, expect, it } from 'vitest' import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { @@ -31,6 +31,12 @@ import { relayLivenessProbeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + createRelayGcListingFixture, + readUncappedRelayGcPaths, + runCappedRelayGcShellCommand, + SSH_EXEC_OUTPUT_CAP_CHARS +} from './ssh-relay-gc-listing.test-fixture' import { RELAY_INSTALL_COMPLETE_FILENAME, relayArtifactFilenames @@ -256,22 +262,43 @@ describe('ssh remote command builders', () => { it.runIf(process.platform !== 'win32')( 'bounds real POSIX GC output with more than the exec-cap stage population', async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) + const fixture = createRelayGcListingFixture() try { - for (let index = 0; index < 15_197; index += 1) { - mkdirSync(join(root, `relay-0.1.0+abc.upload-${String(index).padStart(12, '0')}`)) + const paths = await readUncappedRelayGcPaths(fixture.findCommand) + expect(paths.join('\n').length).toBeGreaterThan(SSH_EXEC_OUTPUT_CAP_CHARS) + const cappedRaw = await runCappedRelayGcShellCommand( + `${fixture.findCommand} | LC_ALL=C sort` + ) + expect(cappedRaw.length).toBe(SSH_EXEC_OUTPUT_CAP_CHARS) + for (const name of fixture.validNames) { + expect(cappedRaw).not.toContain(join(fixture.root, name)) } - mkdirSync(join(root, 'relay-0.1.0+aaa')) - mkdirSync(join(root, 'relay-0.1.0+bbb')) - const output = await runShellCommand(listRelayBaseDirsCommand(posix, root)) + const output = await runCappedRelayGcShellCommand( + listRelayBaseDirsCommand(posix, fixture.root) + ) const entries = output.trim().split('\n') expect(entries).toEqual(['relay-0.1.0+aaa', 'relay-0.1.0+bbb']) expect(Buffer.byteLength(output)).toBeLessThan(1_024) expect(entries.length).toBeLessThanOrEqual(MAX_RELAY_GC_LISTING_ENTRIES) + + fixture.fillValidEntryBoundary() + const validNames = new Set(fixture.validNames) + const unboundedEntries = (await readUncappedRelayGcPaths(fixture.findCommand)) + .map((path) => basename(path)) + .filter((name) => validNames.has(name)) + expect(unboundedEntries).toHaveLength(65) + const boundedOutput = await runCappedRelayGcShellCommand( + listRelayBaseDirsCommand(posix, fixture.root) + ) + const boundedEntries = boundedOutput.trim().split('\n') + expect(boundedEntries).toEqual(unboundedEntries.slice(0, 64)) + expect(boundedEntries).toHaveLength(64) + expect(new Set(boundedEntries).size).toBe(64) + expect(Buffer.byteLength(boundedOutput)).toBeLessThan(1_024) } finally { - rmSync(root, { recursive: true, force: true }) + fixture.dispose() } }, 30_000 diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index 5ebfb17ced1..148ccad8ff6 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -3,6 +3,18 @@ import type { SystemSshCommandChannel } from './system-ssh-command' export type ProcessResult = { label: string; stderr: string } +export class SystemSshCommandExitError extends Error { + constructor( + label: string, + readonly exitCode: number | null, + readonly stderr: string, + signal?: NodeJS.Signals | null + ) { + const detail = exitCode === null ? `signal ${signal ?? 'unknown'}` : `exit ${exitCode}` + super(`${label} failed (${detail}): ${stderr.trim()}`) + } +} + /** * `timeoutMs` bounds a remote consumer that never returns. Windows PowerShell 5.1 cannot drain a * large redirected stdin over a non-pty ssh exec (#16432): the remote process stays alive at idle @@ -52,8 +64,7 @@ export function waitForChannelClose( } const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { if (code !== 0) { - const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` - settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`)) + settle(reject, new SystemSshCommandExitError(label, code, stderr, signal)) return } settle(resolve) diff --git a/src/main/ssh/system-ssh-windows-upload.test.ts b/src/main/ssh/system-ssh-windows-upload.test.ts index c3a5ff80276..bc339f24785 100644 --- a/src/main/ssh/system-ssh-windows-upload.test.ts +++ b/src/main/ssh/system-ssh-windows-upload.test.ts @@ -51,12 +51,16 @@ import { writeBufferViaSystemSsh } from './system-ssh-file-binary-transfer' import { waitForChannelClose } from './system-ssh-operation-lifecycle' +import * as windowsFileWrite from './system-ssh-windows-file-write' import { getRemoteHostPlatform } from './ssh-remote-platform' import { clearWindowsRemoteWriteCapabilitiesForTests, getWindowsRemoteWriteCapabilities } from './system-ssh-windows-write-capabilities' -import { explainWindowsPowerShellStdinFailure } from './system-ssh-windows-write-strategy' +import { + explainWindowsPowerShellStdinFailure, + writeWindowsRemoteFile +} from './system-ssh-windows-write-strategy' import type { SshTarget } from '../../shared/ssh-types' type FakeChannel = EventEmitter & { @@ -106,6 +110,8 @@ const sftpBatches: RecordedSftpBatch[] = [] const commands: RecordedCommand[] = [] /** Index of the exec that should report a non-zero exit, to model a chunk failing mid-file. */ let failAtSpawn = -1 +let failedWriteExit = 1 +let failedWriteStderr = '' let localDir: string const fileWrites = (): RecordedCommand[] => @@ -177,6 +183,8 @@ beforeEach(() => { commands.length = 0 sftpBatches.length = 0 failAtSpawn = -1 + failedWriteExit = 1 + failedWriteStderr = '' clearWindowsRemoteWriteCapabilitiesForTests() waitForChannelCloseSpy.mockClear() localDir = mkdtempSync(join(tmpdir(), 'orca-win-upload-')) @@ -192,9 +200,14 @@ beforeEach(() => { executable: command.split(' ')[0] ?? '', stdin: channel.written }) - setImmediate(() => - spawnIndex === failAtSpawn ? channel.emit('close', 1, null) : channel.emit('close', 0, null) - ) + setImmediate(() => { + if (spawnIndex === failAtSpawn) { + channel.stderr.write(failedWriteStderr) + channel.emit('close', failedWriteExit, null) + } else { + channel.emit('close', 0, null) + } + }) }) }) }) @@ -624,6 +637,229 @@ describe('Windows upload on a host with no sftp subsystem', () => { expect(fileWrites().map(writtenPath)).not.toContain(`${remoteRoot}/relay.js`) expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) }) + + it.each([ + ['relay.js', 'aabb9009eeff'], + ['relay-9009.js', 'aabbccddeeff'], + ['relay-CommandNotFoundException.js', 'aabbccddeeff'], + ['is not recognized as an internal or external command.js', 'aabbccddeeff'] + ])('propagates a failed write whose path contains absence-like text: %s', async (file, nonce) => { + const remotePath = `${remoteRoot}/${file}` + const staging = vi + .spyOn(windowsFileWrite, 'makeWindowsStagingPath') + .mockImplementation((path) => `${path}${WINDOWS_STAGED_WRITE_SUFFIX}-${nonce}`) + writeFileSync(join(localDir, file), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) + failAtSpawn = 0 + try { + await expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('failed (exit 1)') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } finally { + staging.mockRestore() + } + }) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a write-denied stderr naming an absence-like filename: %s', async (file) => { + const remotePath = `${remoteRoot}/${file}` + writeFileSync(join(localDir, file), 'x') + failAtSpawn = 0 + failedWriteStderr = `Access to the path '${remotePath}' is denied.` + + await expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('Access to the path') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + }) + + const capturedPowerShellMissingPwsh = + "pwsh.exe : The term 'pwsh.exe' is not recognized as the name of a cmdlet, function, script file, or operable program. \r\nCheck the spelling of the name, or if a path was included, verify that the path is correct and try again.\r\nAt line:1 char:1\r\n+ pwsh.exe -NoProfile -NonInteractive -Command exit\r\n+ ~~~~~~~~\r\n + CategoryInfo : ObjectNotFound: (pwsh.exe:String) [], CommandNotFoundException\r\n + FullyQualifiedErrorId : CommandNotFoundException" + + // Source-derived resource/layout controls; these are not captured localized Windows errors. + const sourceDerivedLocalizedMissingPwsh = [ + 'pwsh.exe : La commande est introuvable.', + 'Vérifiez le nom de la commande.', + 'À la ligne:1 caractère:1', + '+ pwsh.exe -NoProfile -NonInteractive -EncodedCommand JABwAGEAdABoAA== ...', + '+ ~~~~~~~~', + ' + CategoryInfo : CommandNotFoundException — cible pwsh.exe, type String', + ' + FullyQualifiedErrorId : CommandNotFoundException' + ].join('\r\n') + const sourceDerivedWrappedMissingPwsh = [ + 'pwsh.exe : The term', + "'pwsh.exe' is not recognized.", + 'At line:1 char:1', + '+ pwsh.exe -NoProfile ', + '-NonInteractive -EncodedCommand ', + 'JABwAGEAdABoAA== ...', + '+ ~~~~~~~~', + ' + CategoryInfo : ObjectNotFound: ', + '(pwsh.exe:String) [], CommandNotFoundExcept', + 'ion', + ' + FullyQualifiedErrorId : CommandNotFoundExcept', + 'ion' + ].join('\r\n') + + it.each([ + "'missing-tool.exe' is not recognized as an internal or external command", + `Access to the path 'relay.js' is denied.\n${capturedPowerShellMissingPwsh}`, + `${capturedPowerShellMissingPwsh}\nAccess to the path 'relay.js' is denied.`, + capturedPowerShellMissingPwsh.replaceAll('pwsh.exe', 'missing-tool.exe'), + capturedPowerShellMissingPwsh.replace(/.*CategoryInfo.*\r?\n/, ''), + capturedPowerShellMissingPwsh.replace('At line:', 'pwsh.exe : Another failure.\r\nAt line:'), + capturedPowerShellMissingPwsh.replace('At line:', 'another.exe : Another failure.\r\nAt line:'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible relay-pwsh.exe.js'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible C:\\bin\\pwsh.exe'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible pwsh.exe-backup'), + sourceDerivedLocalizedMissingPwsh.replace('type String', 'type FileInfo'), + sourceDerivedWrappedMissingPwsh.replace('+ pwsh.exe', '+ missing-tool.exe'), + `${sourceDerivedWrappedMissingPwsh}\n${capturedPowerShellMissingPwsh}`, + `${sourceDerivedLocalizedMissingPwsh}\nAccess to the path 'relay.js' is denied.`, + `Access to the path 'relay.js' is denied.\n${sourceDerivedLocalizedMissingPwsh}`, + 'CommandNotFoundException: missing-tool.exe', + "Access to the path 'relay.js' is denied.\nCommandNotFoundException: pwsh.exe", + "Access to the path 'relay.js' is denied.\n'pwsh.exe' is not recognized as an internal or external command" + ])( + 'does not mark PowerShell 7 absent when its script reports another missing command: %s', + async (stderr) => { + writeFileSync(join(localDir, 'relay.js'), 'x') + failAtSpawn = 0 + failedWriteStderr = stderr + + await expect( + uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + ).rejects.toThrow('failed (exit 1)') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } + ) + + it.each([ + [9009, ''], + [1, "'pwsh.exe' is not recognized as an internal or external command"], + [1, 'CommandNotFoundException: pwsh.exe'], + [ + 1, + "'pwsh.exe' is not recognized as an internal or external command,\r\noperable program or batch file." + ], + [1, capturedPowerShellMissingPwsh] + ])('falls back on an actual missing PowerShell 7 signal: %s %s', async (exit, stderr) => { + writeFileSync(join(localDir, 'relay.js'), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) + failAtSpawn = 0 + failedWriteExit = exit + failedWriteStderr = stderr + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + expect(fileWrites().map((write) => write.executable)).toEqual([ + 'pwsh.exe', + 'powershell.exe', + 'powershell.exe', + 'powershell.exe' + ]) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(false) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(true) + }) + + it.each([ + sourceDerivedLocalizedMissingPwsh, + sourceDerivedWrappedMissingPwsh, + sourceDerivedLocalizedMissingPwsh.replace(' -NonInteractive', '\r\n -NonInteractive') + ])( + 'falls back on a source-derived localized or wrapped missing-pwsh record: %s', + async (stderr) => { + writeFileSync(join(localDir, 'relay.js'), 'x') + failAtSpawn = 0 + failedWriteStderr = stderr + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe', 'powershell.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(false) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(true) + } + ) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a timeout whose filename resembles a missing command: %s', async (file) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const remotePath = `${remoteRoot}/${file}` + writeFileSync(join(localDir, file), 'x') + let noteWriteStarted: () => void = () => {} + const writeStarted = new Promise((resolve) => { + noteWriteStarted = resolve + }) + spawnSystemSshCommandMock.mockImplementation((_target: SshTarget, command: string) => { + const executable = command.split(' ')[0] ?? '' + return createFakeChannel((channel) => { + commands.push({ + script: decodePowerShellCommand(command), + executable, + stdin: channel.written + }) + if (executable !== 'pwsh.exe') { + setImmediate(() => channel.emit('close', 0, null)) + } else { + noteWriteStarted() + } + }) + }) + try { + const result = expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('timed out') + await writeStarted + await vi.advanceTimersByTimeAsync(WINDOWS_STDIN_WRITE_TIMEOUT_MS + 1) + await result + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a short source whose filename resembles a missing command: %s', async (file) => { + await expect( + writeWindowsRemoteFile( + target, + `${remoteRoot}/${file}`, + { + totalBytes: 1, + readChunk: async () => Buffer.alloc(0), + withLocalFile: async (send) => send(join(localDir, file)) + }, + {} + ) + ).rejects.toThrow('Source ran short') + + expect(fileWrites()).toHaveLength(0) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + }) }) describe('last-resort Windows PowerShell failure reporting', () => { diff --git a/src/main/ssh/system-ssh-windows-write-strategy.ts b/src/main/ssh/system-ssh-windows-write-strategy.ts index f2cdca12516..e8016db313c 100644 --- a/src/main/ssh/system-ssh-windows-write-strategy.ts +++ b/src/main/ssh/system-ssh-windows-write-strategy.ts @@ -3,6 +3,7 @@ import { getSystemSshBuildArgsFromOperationOptions } from './system-ssh-args' import { spawnSystemSshCommand } from './system-ssh-command' import { awaitWithSystemSshAbort, + SystemSshCommandExitError, throwIfAborted, waitForChannelClose } from './system-ssh-operation-lifecycle' @@ -267,11 +268,42 @@ export function explainWindowsPowerShellStdinFailure(error: unknown): unknown { } function isPwshUnavailableError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - // cmd.exe's "not recognized" and sshd's exit 9009 both mean "no pwsh here". A timeout does not: - // that is the stdin defect, and PowerShell 7 does not have it, so it must not be cached as absent. - return /is not recognized as an internal or external command|9009|CommandNotFoundException/i.test( - message + if (!(error instanceof SystemSshCommandExitError)) { + return false + } + // A complete missing-pwsh diagnostic establishes absence; paths and mixed errors do not. + const stderr = error.stderr.trim() + return ( + error.exitCode === 9009 || + /^'pwsh\.exe' is not recognized as an internal or external command(?:,\r?\noperable program or batch file\.)?$/i.test( + stderr + ) || + /^CommandNotFoundException:[ \t]*pwsh\.exe$/i.test(stderr) || + isPowerShellMissingPwshDiagnostic(stderr) + ) +} + +function isPowerShellMissingPwshDiagnostic(stderr: string): boolean { + // NormalView wraps physical lines; its localized prose and category template are not identifiers. + const record = stderr.replace(/\r?\n/g, '') + const sections = + /^pwsh\.exe[ \t]*:[ \t]*([^+]+)\+[ \t]*pwsh\.exe([ \t]+-[^~]*?)\+[ \t]*~{8}[ \t]*\+[ \t]*CategoryInfo[ \t]*:[ \t]*([^+]+)\+[ \t]*FullyQualifiedErrorId[ \t]*:[ \t]*CommandNotFoundException[ \t]*$/.exec( + record + ) + if (!sections) { + return false + } + const prelude = sections[1] ?? '' + const source = sections[2] ?? '' + const category = sections[3] ?? '' + return ( + !/\b[\w.-]+\.exe[ \t]*:/.test(prelude) && + /^[ \t]+-NoProfile[ \t]+-NonInteractive[ \t]+-(?:Command[ \t]+exit|EncodedCommand[ \t]+[A-Za-z0-9+/=]+(?:[ \t]*\.{3})?)[ \t]*$/.test( + source + ) && + category.match(/(? { expect(beforeQuitStart).toBeGreaterThanOrEqual(0) expect(willQuitStart).toBeGreaterThan(beforeQuitStart) expect(windowAllClosedStart).toBeGreaterThan(willQuitStart) + expect(beforeQuit.indexOf('event.defaultPrevented')).toBeGreaterThanOrEqual(0) + expect(beforeQuit.indexOf('event.defaultPrevented')).toBeLessThan( + beforeQuit.indexOf('state.isQuitting = true') + ) expect(beforeQuit).not.toContain('unsubscribeSystemResumeBroadcast') expect(commitIndex).toBeGreaterThanOrEqual(0) expect(disposeIndex).toBeGreaterThan(commitIndex) diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index c761ccf0409..be618a8605d 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -78,14 +78,28 @@ describe('headless PTY registry hydration ordering', () => { const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') const identityReader = source.indexOf('readObservedAgentStatusPaneIdentity:', runtime) const identitySubscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') - const hooksEnabled = source.indexOf('if (isAgentStatusHooksEnabled(', identitySubscription) + const hookStart = source.indexOf('await agentHookServer.start(', identitySubscription) + const settingsListener = source.indexOf('profileStore.onSettingsChanged(', hookStart) + const daemon = source.indexOf('await startOrcadDaemon()', hookStart) const identityFlush = source.indexOf('observedStatusCapture.attach(runtime)', runtime) expect(runtime).toBeGreaterThanOrEqual(0) expect(identityReader).toBeGreaterThan(runtime) expect(identitySubscription).toBeGreaterThanOrEqual(0) expect(identitySubscription).toBeLessThan(runtime) - expect(hooksEnabled).toBeGreaterThan(identitySubscription) + expect(hookStart).toBeGreaterThan(identitySubscription) + expect(settingsListener).toBeGreaterThan(hookStart) + expect(daemon).toBeGreaterThan(settingsListener) + expect(runtime).toBeGreaterThan(daemon) + expect(source.slice(identitySubscription, hookStart)).not.toContain( + 'if (isAgentStatusHooksEnabled(' + ) + expect(source.slice(hookStart, settingsListener)).toContain( + 'statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings())' + ) + expect(source.slice(settingsListener, daemon)).toContain( + 'agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings))' + ) expect(identityFlush).toBeGreaterThan(runtime) expect(source.slice(identitySubscription, runtime)).toContain( 'observedStatusCapture.observe(enriched)' diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 03cef473299..b5cb284db08 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -171,9 +171,6 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. startAgentHookServer: async () => { const settings = state.store?.getSettings() - if (!isAgentStatusHooksEnabled(settings)) { - return - } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) // Why (#11217): the hook listener fails open on every request error, so an IDS resetting // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. @@ -182,6 +179,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ track('agent_hook_transport_blocked', { count: report.count }) }) await agentHookServer.start({ + statusHooksEnabled: isAgentStatusHooksEnabled(settings), env: app.isPackaged ? 'production' : 'development', // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). userDataPath: app.getPath('userData'), diff --git a/src/main/startup/main-process-quit-update-veto.test.ts b/src/main/startup/main-process-quit-update-veto.test.ts new file mode 100644 index 00000000000..ea46417fe40 --- /dev/null +++ b/src/main/startup/main-process-quit-update-veto.test.ts @@ -0,0 +1,103 @@ +import { EventEmitter } from 'node:events' +import { afterEach, expect, it, vi } from 'vitest' + +const dependencyExports: [string, string[]][] = [ + ['../ipc/filesystem-watcher', ['closeAllWatchers']], + ['../ipc/worktree-base-directory-watcher', ['disposeWorktreeBaseDirectoryWatchers']], + ['../ipc/folder-repo-git-upgrade', ['stopFolderRepoGitUpgradeWatch']], + ['../ipc/pty', ['killAllPty']], + ['../daemon/daemon-init', ['disconnectDaemon', 'shutdownDaemon']], + ['../ipc/ssh-shutdown-drain', ['beginSshShutdown']], + ['../agent-hooks/server', ['agentHookServer']], + ['../agent-hooks/wsl-hook-relay-manager', ['wslHookRelayManager']], + ['../agent-hooks/managed-agent-hook-controls', ['removeManagedAgentHooksAsync']], + ['../runtime/structured-agent-session-runtime', ['stopStructuredAgentSessionRuntime']], + [ + '../runtime/structured-agent-session-runtime-teardown', + ['setStructuredAgentSessionTeardownTrigger'] + ], + ['../runtime/orca-runtime-files', ['awaitRuntimeFileWatcherUnsubscribes']], + ['../runtime/runtime-metadata', ['clearRuntimeMetadataIfOwned']], + [ + '../browser/paired-runtime-browser-client-host-runtime', + ['shutdownPairedRuntimeBrowserClientHosts'] + ], + ['../browser/browser-manager', ['browserManager']], + ['../codex/codex-state-db-backfill-recovery', ['stopCodexStateDbBackfillRecoveries']], + ['../codex/codex-account-session-bridge', ['stopCodexAccountSessionBridges']], + ['../git/local-repo-ref-maintenance', ['awaitPackedRefsLockRelease']], + ['../worktree-background-removal', ['stopBackgroundWorktreeRemovals']], + ['../quit-teardown-deadline', ['settleTeardownWithinDeadline', 'settleWithinMs']], + ['../quit-teardown-start-gate', ['quitTeardownStartGate']], + ['../dock/unread-badge', ['setUnreadDockBadgeCount']], + ['../tray/system-tray', ['destroySystemTray']], + ['../telemetry/client', ['shutdownTelemetry']], + ['../observability', ['shutdownObservability']], + ['../updater', ['isQuittingForUpdate']], + ['../updater-lifecycle-diagnostics', ['recordUpdaterLifecycle']], + ['../macos-tcc-prompt-notice', ['stopTccPromptNotice']], + ['../terminal-history-gc', ['cancelHistoryGc']], + ['./window-all-closed-quit-policy', ['shouldQuitWhenAllWindowsClosed']], + ['./configure-process', ['isDevParentShutdownRequested']], + ['../persistence', ['getCanonicalUserDataPath']] +] + +it('keeps startup services live when the updater has vetoed before-quit', async () => { + vi.resetModules() + const app = new EventEmitter() + const fenceAndCloseNow = vi.fn() + const setMobileRelayPairingProvider = vi.fn() + const unsubscribeAgentAwakeStatusChanges = vi.fn() + const dispose = vi.fn() + const stop = vi.fn() + const state = { + isQuitting: false, + desktopRelayService: { fenceAndCloseNow }, + runtimeRpc: { setMobileRelayPairingProvider }, + unsubscribeAgentAwakeStatusChanges, + agentAwakeService: { dispose }, + rateLimits: { stop } + } + vi.doMock('electron', () => ({ app })) + vi.doMock('./main-process-state', () => ({ mainProcessState: state })) + for (const [moduleName, exports] of dependencyExports) { + vi.doMock(moduleName, () => Object.fromEntries(exports.map((name) => [name, vi.fn()]))) + } + const exitListenersBefore = process.listeners('exit') + const { installMainProcessQuitHandlers } = await import('./main-process-quit') + installMainProcessQuitHandlers() + + app.emit('before-quit', { defaultPrevented: true }) + + expect(state.isQuitting).toBe(false) + expect(fenceAndCloseNow).not.toHaveBeenCalled() + expect(setMobileRelayPairingProvider).not.toHaveBeenCalled() + expect(unsubscribeAgentAwakeStatusChanges).not.toHaveBeenCalled() + expect(dispose).not.toHaveBeenCalled() + expect(stop).not.toHaveBeenCalled() + expect(state.agentAwakeService).toEqual({ dispose }) + expect(state.unsubscribeAgentAwakeStatusChanges).toBe(unsubscribeAgentAwakeStatusChanges) + + app.emit('before-quit', { defaultPrevented: false }) + + expect(state.isQuitting).toBe(true) + expect(fenceAndCloseNow).toHaveBeenCalledOnce() + expect(setMobileRelayPairingProvider).toHaveBeenCalledWith(null) + expect(unsubscribeAgentAwakeStatusChanges).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + expect(stop).toHaveBeenCalledOnce() + for (const listener of process.listeners('exit')) { + if (!exitListenersBefore.includes(listener)) { + process.removeListener('exit', listener) + } + } +}) + +afterEach(() => { + vi.doUnmock('electron') + vi.doUnmock('./main-process-state') + for (const [moduleName] of dependencyExports) { + vi.doUnmock(moduleName) + } + vi.resetModules() +}) diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 5a89c9e283f..7f218b93e6c 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -67,7 +67,10 @@ function shutdownWatchersOnce(): Promise { } function installBeforeQuitHandler(): void { - app.on('before-quit', () => { + app.on('before-quit', (event: Event) => { + if (event.defaultPrevented) { + return + } if (isQuittingForUpdate()) { recordUpdaterLifecycle('before_quit_allowed', undefined, { message: 'before-quit allowed for update install' diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 238998bac49..c9ec3b30368 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -49,6 +49,7 @@ import { syncMacMenuBarIcon } from './main-window-actions' import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { reportProfileStateWriteFailure } from './profile-state-write-failure' @@ -248,6 +249,7 @@ export async function initializeReadyFoundation(): Promise { syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running // guest process and timers, and on must restart them — otherwise open WSL panes report no // status until their next spawn. diff --git a/src/main/updater-events.test.ts b/src/main/updater-events.test.ts index 6a643ae64a5..6f5ce5fbb87 100644 --- a/src/main/updater-events.test.ts +++ b/src/main/updater-events.test.ts @@ -12,7 +12,8 @@ const { appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), - on: vi.fn() + on: vi.fn(), + prependListener: vi.fn() }, nativeUpdaterMock: { on: vi.fn() }, getLinuxPackageTypeMock: vi.fn<() => 'deb' | 'rpm' | 'non-root' | 'unusable'>(() => 'deb'), diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index 62ec3ff3b8c..e108469acdd 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -51,6 +51,7 @@ export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean left.message === right.message && left.version === right.version && left.retryable === right.retryable && + left.retryAction === right.retryAction && left.userInitiated === right.userInitiated && left.activeNudgeId === right.activeNudgeId && // Recovery identity fences async actions, so same-valued recaptures must reach the renderer. diff --git a/src/main/updater-linux-package-recovery-actions.test.ts b/src/main/updater-linux-package-recovery-actions.test.ts index 9a546dd8fac..60912443180 100644 --- a/src/main/updater-linux-package-recovery-actions.test.ts +++ b/src/main/updater-linux-package-recovery-actions.test.ts @@ -38,7 +38,13 @@ const { } } return { - appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: vi.fn(), quit: vi.fn() }, + appMock: { + isPackaged: true, + getVersion: vi.fn(() => '1.0.51'), + on: vi.fn(), + prependListener: vi.fn(), + quit: vi.fn() + }, autoUpdaterMock, clearTrackedLinuxPackageArtifactMock: vi.fn(), getTrackedLinuxPackageArtifactMock: vi.fn(), diff --git a/src/main/updater-mac-install.ts b/src/main/updater-mac-install.ts index 234cdc0b2c4..ee45db2d0f4 100644 --- a/src/main/updater-mac-install.ts +++ b/src/main/updater-mac-install.ts @@ -34,11 +34,17 @@ export function registerMacUpdaterEvents({ }) } - app.on('before-quit', (event) => { + // Why: veto before startup listeners begin shutting down services. + app.prependListener('before-quit', (event) => { if (!shouldDeferMacQuitForInstall()) { return } - if (consumeMacInstallGuardBypass()) { + // Why: an Update & Restart is checking blockers or cleaning up; a second quit must not tear down underneath it. + if (macInstallPreflightInProgress) { + event.preventDefault() + return + } + if (shouldBypassMacInstallGuard()) { recordUpdaterLifecycle('macos_before_quit_guard_bypassed') return } @@ -50,7 +56,8 @@ export function registerMacUpdaterEvents({ getCurrentStatus(), hasInstallableDownloadedVersion(), getPendingInstallVersion, - sendStatus + sendStatus, + 'quit' ) ) { recordUpdaterLifecycle('macos_before_quit_deferred', { @@ -63,16 +70,23 @@ export function registerMacUpdaterEvents({ /** Whether Squirrel.Mac has finished downloading the update from the localhost proxy. */ let squirrelReady = false +let macInstallPreflightInProgress = false + +export function setMacInstallPreflightInProgress(value: boolean): void { + macInstallPreflightInProgress = value + if (value) { + bypassMacInstallGuardUntilNextAttempt = false + } +} /** Remembers a user/app quit request that arrived before Squirrel.Mac had a * staged update ready to apply. Without this handoff, quitting during the * localhost-proxy phase exits back into the old app and the update is lost. */ -let installRequestedAfterSquirrelReady = false +let requestedActionAfterSquirrelReady: 'quit' | 'install' | null = null /** Prevents the updater-specific before-quit guard from re-blocking the * quitAndInstall-triggered shutdown that is supposed to apply the update. */ let quitAndInstallInFlight = false -/** Lets a timed-out quit attempt proceed exactly once so the app never gets - * trapped open if Squirrel.Mac stops short of the native ready signal. */ -let bypassMacInstallGuardOnce = false +/** Both quit passes must proceed when native readiness times out. */ +let bypassMacInstallGuardUntilNextAttempt = false let pendingInstallTimeout: ReturnType | null = null function clearPendingInstallTimeout(): void { @@ -83,9 +97,10 @@ function clearPendingInstallTimeout(): void { } export function resetMacInstallState(): void { - installRequestedAfterSquirrelReady = false + macInstallPreflightInProgress = false + requestedActionAfterSquirrelReady = null quitAndInstallInFlight = false - bypassMacInstallGuardOnce = false + bypassMacInstallGuardUntilNextAttempt = false clearPendingInstallTimeout() } @@ -95,18 +110,18 @@ export function beginMacUpdateDownload(): void { } export function markMacQuitAndInstallInFlight(): void { - installRequestedAfterSquirrelReady = false + requestedActionAfterSquirrelReady = null quitAndInstallInFlight = true - bypassMacInstallGuardOnce = false + bypassMacInstallGuardUntilNextAttempt = false clearPendingInstallTimeout() } -export function consumeMacInstallGuardBypass(): boolean { - if (!bypassMacInstallGuardOnce) { - return false - } - bypassMacInstallGuardOnce = false - return true +function shouldBypassMacInstallGuard(): boolean { + return bypassMacInstallGuardUntilNextAttempt +} + +export function isMacInstallRequested(): boolean { + return requestedActionAfterSquirrelReady === 'install' } export function isMacQuitAndInstallInFlight(): boolean { @@ -135,13 +150,20 @@ export function deferMacQuitUntilInstallerReady( currentStatus: UpdateStatus, hasNewerDownloadedVersion: boolean, getPendingInstallVersion: () => string, - sendStatus: (status: UpdateStatus) => void + sendStatus: (status: UpdateStatus) => void, + intent: 'quit' | 'install' = 'install' ): boolean { if (!isWaitingForMacInstallerReadiness(currentStatus, hasNewerDownloadedVersion)) { return false } - installRequestedAfterSquirrelReady = true + if (intent === 'install') { + bypassMacInstallGuardUntilNextAttempt = false + } + // Why: an ordinary retry of quit must not downgrade a pending Update & Restart. + if (intent === 'install' || requestedActionAfterSquirrelReady !== 'install') { + requestedActionAfterSquirrelReady = intent + } sendStatus({ state: 'downloading', percent: 100, version: getPendingInstallVersion() }) if (pendingInstallTimeout) { @@ -150,7 +172,7 @@ export function deferMacQuitUntilInstallerReady( pendingInstallTimeout = setTimeout(() => { pendingInstallTimeout = null - if (!installRequestedAfterSquirrelReady || quitAndInstallInFlight) { + if (!requestedActionAfterSquirrelReady || quitAndInstallInFlight) { return } @@ -162,11 +184,11 @@ export function deferMacQuitUntilInstallerReady( message: `macOS installer was not ready after ${MAC_INSTALL_READY_TIMEOUT_MS}ms; allowing quit without install` } ) - installRequestedAfterSquirrelReady = false + requestedActionAfterSquirrelReady = null // This is a safety valve. The updater path should wait for ShipIt so the // staged update can apply, but if the native ready signal never arrives we // must let the app close instead of trapping the user in a blocked quit. - bypassMacInstallGuardOnce = true + bypassMacInstallGuardUntilNextAttempt = true app.quit() }, MAC_INSTALL_READY_TIMEOUT_MS) @@ -181,14 +203,24 @@ export function handleMacInstallerReady( squirrelReady = true clearPendingInstallTimeout() recordUpdaterLifecycle('macos_installer_ready', { - deferredInstallRequested: installRequestedAfterSquirrelReady, + deferredInstallRequested: requestedActionAfterSquirrelReady === 'install', + deferredQuitRequested: requestedActionAfterSquirrelReady === 'quit', hasNewerDownloadedVersion }) - if (installRequestedAfterSquirrelReady && hasNewerDownloadedVersion) { + if (requestedActionAfterSquirrelReady === 'quit') { + requestedActionAfterSquirrelReady = null + app.quit() + return + } + + if (requestedActionAfterSquirrelReady === 'install' && hasNewerDownloadedVersion) { + setMacInstallPreflightInProgress(true) void Promise.resolve() .then(() => onReadyToInstall()) .catch((error) => { + requestedActionAfterSquirrelReady = null + setMacInstallPreflightInProgress(false) recordUpdaterLifecycle( 'macos_deferred_install_handoff_failed', { errorType: error instanceof Error ? error.name : typeof error }, @@ -198,6 +230,7 @@ export function handleMacInstallerReady( return } + requestedActionAfterSquirrelReady = null if (hasNewerDownloadedVersion) { onReadyToReportDownloaded() } diff --git a/src/main/updater-mac-quit-guard.test.ts b/src/main/updater-mac-quit-guard.test.ts new file mode 100644 index 00000000000..2819b328c8a --- /dev/null +++ b/src/main/updater-mac-quit-guard.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { appMock, nativeUpdaterMock } = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + return { + appMock: Object.assign(new EventEmitter(), { quit: vi.fn() }), + nativeUpdaterMock: new EventEmitter() + } +}) + +vi.mock('electron', () => ({ app: appMock, autoUpdater: nativeUpdaterMock })) +vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: vi.fn() })) + +import { + beginMacUpdateDownload, + deferMacQuitUntilInstallerReady, + handleMacInstallerReady, + isMacInstallRequested, + markMacQuitAndInstallInFlight, + registerMacUpdaterEvents, + resetMacInstallState, + setMacInstallPreflightInProgress +} from './updater-mac-install' + +function quitEvent(): { defaultPrevented: boolean; preventDefault: () => void } { + const event = { + defaultPrevented: false, + preventDefault: () => { + event.defaultPrevented = true + } + } + return event +} + +function registerGuard( + hasUpdate: boolean, + performQuitAndInstall = vi.fn(), + status: 'downloaded' | 'downloading' = 'downloaded' +): void { + registerMacUpdaterEvents({ + getCurrentStatus: () => + status === 'downloaded' + ? { state: 'downloaded', version: '2.0.0' } + : { state: 'downloading', percent: 100, version: '2.0.0' }, + hasInstallableDownloadedVersion: () => hasUpdate, + getPendingInstallVersion: () => '2.0.0', + getKnownReleaseUrl: () => undefined, + performQuitAndInstall, + shouldDeferMacQuitForInstall: () => true, + sendStatus: vi.fn() + }) +} + +describe.runIf(process.platform === 'darwin')('macOS quit guard ordering', () => { + beforeEach(() => { + appMock.removeAllListeners() + nativeUpdaterMock.removeAllListeners() + beginMacUpdateDownload() + appMock.quit.mockReset() + }) + + afterEach(() => { + resetMacInstallState() + vi.useRealTimers() + }) + + it('resumes an ordinary quit after native readiness without starting a relaunching install', () => { + const install = vi.fn() + registerGuard(true, install, 'downloading') + const firstQuit = quitEvent() + appMock.emit('before-quit', firstQuit) + expect(firstQuit.defaultPrevented).toBe(true) + + nativeUpdaterMock.emit('update-downloaded') + + expect(appMock.quit).toHaveBeenCalledOnce() + expect(install).not.toHaveBeenCalled() + for (let pass = 0; pass < 2; pass++) { + const resumedQuit = quitEvent() + appMock.emit('before-quit', resumedQuit) + expect(resumedQuit.defaultPrevented).toBe(false) + } + }) + + it('keeps an explicit deferred install when an ordinary quit arrives afterward', async () => { + const install = vi.fn() + registerGuard(true, install, 'downloading') + expect( + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + ).toBe(true) + const ordinaryQuit = quitEvent() + appMock.emit('before-quit', ordinaryQuit) + expect(ordinaryQuit.defaultPrevented).toBe(true) + nativeUpdaterMock.emit('update-downloaded') + const quitBeforeInstallCallback = quitEvent() + appMock.emit('before-quit', quitBeforeInstallCallback) + expect(quitBeforeInstallCallback.defaultPrevented).toBe(true) + await Promise.resolve() + + expect(install).toHaveBeenCalledOnce() + expect(appMock.quit).not.toHaveBeenCalled() + }) + + it('releases the requested install when readiness has no installable version', () => { + const install = vi.fn() + registerGuard(true, install, 'downloading') + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + expect(isMacInstallRequested()).toBe(true) + handleMacInstallerReady(false, install, vi.fn()) + expect(isMacInstallRequested()).toBe(false) + expect(install).not.toHaveBeenCalled() + }) + + it('releases the readiness handoff guard when the install callback rejects', async () => { + registerGuard(true, vi.fn(), 'downloading') + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + handleMacInstallerReady( + true, + () => { + throw new Error('handoff rejected') + }, + vi.fn() + ) + await Promise.resolve() + await Promise.resolve() + expect(isMacInstallRequested()).toBe(false) + const ordinaryQuit = quitEvent() + appMock.emit('before-quit', ordinaryQuit) + expect(ordinaryQuit.defaultPrevented).toBe(false) + }) + + it('allows both timeout shutdown passes and revokes that allowance for a new deferred install', async () => { + vi.useFakeTimers() + const install = vi.fn() + registerGuard(true, install, 'downloading') + appMock.emit('before-quit', quitEvent()) + await vi.advanceTimersByTimeAsync(15_000) + expect(appMock.quit).toHaveBeenCalledOnce() + for (let pass = 0; pass < 2; pass++) { + const timeoutQuit = quitEvent() + appMock.emit('before-quit', timeoutQuit) + expect(timeoutQuit.defaultPrevented).toBe(false) + } + + deferMacQuitUntilInstallerReady( + { state: 'downloading', percent: 100, version: '2.0.0' }, + true, + () => '2.0.0', + vi.fn() + ) + const retryQuit = quitEvent() + appMock.emit('before-quit', retryQuit) + expect(retryQuit.defaultPrevented).toBe(true) + nativeUpdaterMock.emit('update-downloaded') + await Promise.resolve() + expect(install).toHaveBeenCalledOnce() + }) + + it('vetoes a quit during install preflight before previously registered startup services shut down', () => { + const shutdown = vi.fn() + appMock.on('before-quit', (event) => { + if (!event.defaultPrevented) { + shutdown() + } + }) + registerGuard(true) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + setMacInstallPreflightInProgress(true) + const event = quitEvent() + + appMock.emit('before-quit', event) + + expect(event.defaultPrevented).toBe(true) + expect(shutdown).not.toHaveBeenCalled() + }) + + it('lets an ordinary quit with a staged update exit instead of converting it into a relaunching install', () => { + // Why: restart flows call app.relaunch() then app.quit(); converting that quit into + // quitAndInstall would race the relaunched old app against ShipIt. + const install = vi.fn() + registerGuard(true, install) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + const event = quitEvent() + + appMock.emit('before-quit', event) + + expect(event.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + }) + + it('vetoes duplicate quits through cleanup and allows the native install shutdown', () => { + registerGuard(true) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + setMacInstallPreflightInProgress(true) + markMacQuitAndInstallInFlight() + for (let attempt = 0; attempt < 2; attempt++) { + const event = quitEvent() + appMock.emit('before-quit', event) + expect(event.defaultPrevented).toBe(true) + } + + setMacInstallPreflightInProgress(false) + const nativeQuit = quitEvent() + appMock.emit('before-quit', nativeQuit) + expect(nativeQuit.defaultPrevented).toBe(false) + }) + + it('allows both ordinary quit passes after refusal and vetoes a new install attempt', () => { + const install = vi.fn() + registerGuard(true, install) + handleMacInstallerReady(true, vi.fn(), vi.fn()) + resetMacInstallState() + const normalQuit = quitEvent() + appMock.emit('before-quit', normalQuit) + expect(normalQuit.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + + const teardownQuit = quitEvent() + appMock.emit('before-quit', teardownQuit) + expect(teardownQuit.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + + setMacInstallPreflightInProgress(true) + const retryQuit = quitEvent() + appMock.emit('before-quit', retryQuit) + expect(retryQuit.defaultPrevented).toBe(true) + expect(install).not.toHaveBeenCalled() + }) + + it('allows ordinary quits when no update is available', () => { + const install = vi.fn() + registerGuard(false, install) + const event = quitEvent() + appMock.emit('before-quit', event) + expect(event.defaultPrevented).toBe(false) + expect(install).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/updater-test-harness.ts b/src/main/updater-test-harness.ts index 83379b7ac9b..955ae25058a 100644 --- a/src/main/updater-test-harness.ts +++ b/src/main/updater-test-harness.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { afterAll, vi } from 'vitest' import type { Mock } from 'vitest' import { clearTrackedRealTimers, trackRealTimers } from './updater-test-timer-tracking' @@ -28,6 +29,7 @@ type AppMock = { isPackaged: boolean getVersion: Mock<() => string> on: Mock<(event: string, handler: (...args: unknown[]) => void) => AppMock> + prependListener: Mock<(event: string, handler: (...args: unknown[]) => void) => AppMock> emit: (event: string, ...args: unknown[]) => void quit: UpdaterSpy } @@ -102,33 +104,25 @@ export const PRE_COMMIT_INSTALL_FAILURE = * `vi.hoisted` block so the mocks exist before the mock factories run. */ export function createUpdaterMocks(): UpdaterMocks { - const appEventHandlers = new Map void)[]>() - const eventHandlers = new Map void)[]>() - + const appEvents = new EventEmitter() + const updaterEvents = new EventEmitter() const appOn = vi.fn((event: string, handler: (...args: unknown[]) => void) => { - const handlers = appEventHandlers.get(event) ?? [] - handlers.push(handler) - appEventHandlers.set(event, handlers) + appEvents.on(event, handler) return appMock }) - - const appEmit = (event: string, ...args: unknown[]) => { - for (const handler of appEventHandlers.get(event) ?? []) { - handler(...args) - } + const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appEvents.prependListener(event, handler) + return appMock + }) + const appEmit = (event: string, ...args: unknown[]): void => { + appEvents.emit(event, ...args) } - const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { - const handlers = eventHandlers.get(event) ?? [] - handlers.push(handler) - eventHandlers.set(event, handlers) + updaterEvents.on(event, handler) return autoUpdaterMock }) - - const emit = (event: string, ...args: unknown[]) => { - for (const handler of eventHandlers.get(event) ?? []) { - handler(...args) - } + const emit = (event: string, ...args: unknown[]): void => { + updaterEvents.emit(event, ...args) } // Why: `vi.resetModules()` abandons the previous test's `updater` module instance but cannot cancel @@ -161,9 +155,10 @@ export function createUpdaterMocks(): UpdaterMocks { const reset = () => { currentGeneration += 1 - appEventHandlers.clear() + appEvents.removeAllListeners() appOn.mockClear() - eventHandlers.clear() + appPrependListener.mockClear() + updaterEvents.removeAllListeners() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null) autoUpdaterMock.downloadUpdate.mockReset() @@ -201,6 +196,7 @@ export function createUpdaterMocks(): UpdaterMocks { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: appOn, + prependListener: appPrependListener, emit: appEmit, quit: vi.fn() } diff --git a/src/main/updater.check-failure.test.ts b/src/main/updater.check-failure.test.ts index 62d228bf3a7..5093052290f 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -20,6 +20,13 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, return appMock }) + const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = appEventHandlers.get(event) ?? [] + handlers.unshift(handler) + appEventHandlers.set(event, handlers) + return appMock + }) + const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { const handlers = eventHandlers.get(event) ?? [] handlers.push(handler) @@ -36,6 +43,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, const reset = () => { appEventHandlers.clear() appOn.mockClear() + appPrependListener.mockClear() eventHandlers.clear() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset() @@ -61,6 +69,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: appOn, + prependListener: appPrependListener, quit: vi.fn() }, browserWindowMock: { diff --git a/src/main/updater.check-preflight.test.ts b/src/main/updater.check-preflight.test.ts index 6b2618aad05..5e1476e8fb9 100644 --- a/src/main/updater.check-preflight.test.ts +++ b/src/main/updater.check-preflight.test.ts @@ -4,6 +4,7 @@ import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-load const { appMock, autoUpdaterMock, + nativeUpdaterMock, fetchChangelogMock, fetchNewerReleaseTagsMock, moduleFactories, @@ -31,6 +32,93 @@ describe('updater', () => { resetUpdaterMocks() }) + it('keeps the staged target when installation cancels a queued background feed check', async () => { + vi.useFakeTimers() + let resolveQueuedTags: (value: { tags: string[]; state: 'ready' }) => void = () => {} + fetchNewerReleaseTagsMock + .mockResolvedValueOnce({ tags: ['v1.0.61'], state: 'ready' }) + .mockImplementationOnce( + () => + new Promise<{ tags: string[]; state: 'ready' }>((resolve) => { + resolveQueuedTags = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + autoUpdaterMock.downloadUpdate.mockResolvedValue([]) + let rejectCleanup: (error: Error) => void = () => {} + const onBeforeQuit = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectCleanup = reject + }) + ) + const send = vi.fn() + const { + setupAutoUpdater, + checkForUpdatesFromMenu, + checkForUpdates, + downloadUpdate, + quitAndInstall, + getUpdateStatus + } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater reads only webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now(), + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + checkForUpdatesFromMenu() + await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce()) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + await vi.advanceTimersByTimeAsync(0) + downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + const nativeReady = nativeUpdaterMock.on.mock.calls.find( + ([event]) => event === 'update-downloaded' + )?.[1] + if (typeof nativeReady === 'function') { + nativeReady() + } + expect(getUpdateStatus()).toEqual( + expect.objectContaining({ + state: 'downloaded', + version: '1.0.61' + }) + ) + const stagedFeed = autoUpdaterMock.setFeedURL.mock.calls.at(-1) + + checkForUpdates() + await vi.waitFor(() => expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(2)) + quitAndInstall() + await vi.advanceTimersByTimeAsync(100) + expect(onBeforeQuit).toHaveBeenCalledOnce() + resolveQueuedTags({ tags: ['v1.0.71'], state: 'ready' }) + await vi.advanceTimersByTimeAsync(1000) + + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce() + expect(autoUpdaterMock.setFeedURL.mock.calls.at(-1)).toEqual(stagedFeed) + expect(getUpdateStatus()).toEqual( + expect.objectContaining({ + state: 'downloaded', + version: '1.0.61' + }) + ) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + + rejectCleanup(new Error('required checkpoint failed')) + await vi.advanceTimersByTimeAsync(0) + expect(getUpdateStatus().state).toBe('error') + autoUpdaterMock.downloadUpdate.mockClear() + downloadUpdate() + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledOnce() + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'downloading', + percent: 0, + version: '1.0.61' + }) + }) + it('ignores stale updater events while a new check is still in feed preflight', async () => { vi.useFakeTimers() let resolveSecondTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {} diff --git a/src/main/updater.fallback.test.ts b/src/main/updater.fallback.test.ts index 767fef421e3..0b2912bcced 100644 --- a/src/main/updater.fallback.test.ts +++ b/src/main/updater.fallback.test.ts @@ -101,6 +101,7 @@ describe('statusesEqual', () => { expect(statusesEqual(error, { ...error, version: '1.0.62' })).toBe(false) expect(statusesEqual(error, { ...error, retryable: true })).toBe(false) + expect(statusesEqual(error, { ...error, retryAction: 'install' })).toBe(false) expect(statusesEqual(error, { ...error })).toBe(true) }) }) diff --git a/src/main/updater.headless-serve-install.test.ts b/src/main/updater.headless-serve-install.test.ts index bae6474cc66..ebf7fb6269b 100644 --- a/src/main/updater.headless-serve-install.test.ts +++ b/src/main/updater.headless-serve-install.test.ts @@ -31,6 +31,10 @@ const { appHandlers.set(event, [...(appHandlers.get(event) ?? []), handler]) return appMock }), + prependListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appHandlers.set(event, [handler, ...(appHandlers.get(event) ?? [])]) + return appMock + }), emit: (event: string, ...args: unknown[]) => emit(appHandlers, event, ...args), quit: vi.fn() } diff --git a/src/main/updater.install-failure-cause.test.ts b/src/main/updater.install-failure-cause.test.ts index bef63d6912a..95b539ca00e 100644 --- a/src/main/updater.install-failure-cause.test.ts +++ b/src/main/updater.install-failure-cause.test.ts @@ -23,6 +23,13 @@ const { return appMock }) + const appPrependListener = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = appEventHandlers.get(event) ?? [] + handlers.unshift(handler) + appEventHandlers.set(event, handlers) + return appMock + }) + const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { const handlers = eventHandlers.get(event) ?? [] handlers.push(handler) @@ -39,6 +46,7 @@ const { const reset = () => { appEventHandlers.clear() appOn.mockClear() + appPrependListener.mockClear() eventHandlers.clear() on.mockClear() autoUpdaterMock.checkForUpdates.mockReset() @@ -64,6 +72,7 @@ const { isPackaged: true, getVersion: vi.fn(() => '1.4.162'), on: appOn, + prependListener: appPrependListener, quit: vi.fn(), exit: vi.fn() }, diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index 563b8562fd6..a3faef056ef 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -8,7 +8,8 @@ const { autoUpdaterMock, shellMock, isMock, - killAllPtyMock + killAllPtyMock, + getMacUpdateRunningInstancesMock } = vi.hoisted(() => { const appEventHandlers = new Map void)[]>() const eventHandlers = new Map void)[]>() @@ -66,6 +67,9 @@ const { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), on: appOn, + prependListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appEventHandlers.set(event, [handler, ...(appEventHandlers.get(event) ?? [])]) + }), emit: appEmit, quit: vi.fn() }, @@ -80,6 +84,7 @@ const { openExternal: vi.fn() }, isMock: { dev: false }, + getMacUpdateRunningInstancesMock: vi.fn(async (): Promise => []), killAllPtyMock: vi.fn() } }) @@ -118,8 +123,25 @@ vi.mock('./updater-nudge', () => ({ shouldApplyNudge: vi.fn().mockReturnValue(false) })) +vi.mock('./macos-update-running-instances', () => ({ + getMacUpdateRunningInstances: getMacUpdateRunningInstancesMock +})) + warmUpdaterModule() +async function prepareStagedMacUpdate(downloadUpdate: () => void): Promise { + await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + await vi.advanceTimersByTimeAsync(0) + downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + const nativeReady = nativeUpdaterMock.on.mock.calls.find( + ([event]) => event === 'update-downloaded' + )?.[1] + nativeReady?.() +} + describe('updater mac install handoff', () => { beforeEach(() => { vi.resetModules() @@ -134,18 +156,21 @@ describe('updater mac install handoff', () => { appMock.isPackaged = true isMock.dev = false killAllPtyMock.mockReset() + getMacUpdateRunningInstancesMock.mockReset().mockResolvedValue([]) autoUpdaterMock.downloadUpdate.mockResolvedValue([]) vi.unstubAllGlobals() vi.useRealTimers() }) it.runIf(process.platform === 'darwin')( - 'waits for Squirrel.Mac before honoring a manual quit that should install the update', + 'resumes an ordinary quit when Squirrel becomes ready without checking blockers or relaunching', async () => { + vi.useFakeTimers() const sendMock = vi.fn() const mainWindow = { webContents: { send: sendMock } } autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule() setupAutoUpdater(mainWindow as never) @@ -156,7 +181,7 @@ describe('updater mac install handoff', () => { autoUpdaterMock.emit('update-available', { version: '1.0.61' }) // Why: the update-available handler is now async (it awaits fetchChangelog). // Flush microtasks so setAvailableVersion runs before update-downloaded fires. - await new Promise((r) => setTimeout(r, 0)) + await vi.advanceTimersByTimeAsync(0) downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) @@ -173,9 +198,14 @@ describe('updater mac install handoff', () => { nativeDownloadedHandler?.() - await vi.waitFor(() => { - expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true) - }) + await vi.advanceTimersByTimeAsync(0) + expect(appMock.quit).toHaveBeenCalledOnce() + expect(getMacUpdateRunningInstancesMock).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + const resumedPreventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault: resumedPreventDefault }) + appMock.emit('before-quit', { preventDefault: resumedPreventDefault }) + expect(resumedPreventDefault).not.toHaveBeenCalled() expect(sendMock).toHaveBeenCalledWith('updater:status', { state: 'downloading', percent: 100, @@ -184,6 +214,60 @@ describe('updater mac install handoff', () => { } ) + it.runIf(process.platform === 'darwin')( + 'checks blockers for an explicit install requested before Squirrel becomes ready', + async () => { + vi.useFakeTimers() + const send = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + const { + setupAutoUpdater, + downloadUpdate, + quitAndInstall, + isQuittingForUpdate, + checkForUpdates, + checkForUpdatesFromMenu, + getUpdateStatus + } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater reads only webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never) + await vi.waitFor(() => expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce()) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.0.61' }) + await vi.advanceTimersByTimeAsync(0) + downloadUpdate() + autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + quitAndInstall() + checkForUpdatesFromMenu() + checkForUpdatesFromMenu({ localBuild: true }) + checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.70' }) + checkForUpdates() + downloadUpdate() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledOnce() + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledOnce() + expect(getUpdateStatus()).toEqual( + expect.objectContaining({ state: 'downloading', percent: 100, version: '1.0.61' }) + ) + const nativeReady = nativeUpdaterMock.on.mock.calls.find( + ([event]) => event === 'update-downloaded' + )?.[1] + nativeReady?.() + await vi.advanceTimersByTimeAsync(0) + + expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledOnce() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(appMock.quit).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'error', version: '1.0.61', retryAction: 'install' }) + ) + } + ) + it.runIf(process.platform === 'darwin')( 'ignores duplicate quit requests while deferred mac install cleanup is running', async () => { @@ -211,6 +295,7 @@ describe('updater mac install handoff', () => { downloadUpdate() autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' }) + quitAndInstall() const preventDefault = vi.fn() appMock.emit('before-quit', { preventDefault }) expect(preventDefault).toHaveBeenCalledTimes(1) @@ -224,6 +309,7 @@ describe('updater mac install handoff', () => { await vi.advanceTimersByTimeAsync(0) expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledOnce() expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() quitAndInstall() @@ -309,7 +395,305 @@ describe('updater mac install handoff', () => { const secondPreventDefault = vi.fn() appMock.emit('before-quit', { preventDefault: secondPreventDefault }) expect(secondPreventDefault).not.toHaveBeenCalled() + appMock.emit('before-quit', { preventDefault: secondPreventDefault }) + expect(secondPreventDefault).not.toHaveBeenCalled() expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() } ) + + it.runIf(process.platform === 'darwin')( + 'keeps the app and terminals open when another bundle instance blocks installation, then allows retry', + async () => { + vi.useFakeTimers() + const send = vi.fn() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall, isQuittingForUpdate } = + await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654, 10718]) + + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(killAllPtyMock).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('9654, 10718'), + retryAction: 'install' + }) + ) + + getMacUpdateRunningInstancesMock.mockResolvedValue([]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + } + ) + + it.runIf(process.platform === 'darwin')( + 'keeps the app open when process enumeration fails', + async () => { + vi.useFakeTimers() + const send = vi.fn() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall, isQuittingForUpdate } = + await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockRejectedValue(new Error('probe failed')) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + expect(isQuittingForUpdate()).toBe(false) + expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted') + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'error', + message: expect.stringContaining('Could not check'), + retryAction: 'install' + }) + ) + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + message: expect.stringMatching(/close the other Orca instances.*quit Orca/i) + }) + ) + } + ) + + it.runIf(process.platform === 'darwin')( + 'leaves an ordinary quit with a staged update to Squirrel instead of starting a relaunching install', + async () => { + vi.useFakeTimers() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + appMock.emit('before-quit', { preventDefault }) + await vi.advanceTimersByTimeAsync(0) + expect(preventDefault).not.toHaveBeenCalled() + expect(getMacUpdateRunningInstancesMock).not.toHaveBeenCalled() + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + } + ) + + it.runIf(process.platform === 'darwin')( + 'blocks duplicate quits while the running-instance check is pending', + async () => { + vi.useFakeTimers() + const onBeforeQuit = vi.fn() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + + let finishProbe: (pids: number[]) => void = () => {} + getMacUpdateRunningInstancesMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(getMacUpdateRunningInstancesMock).toHaveBeenCalledTimes(1) + finishProbe([9654]) + await vi.advanceTimersByTimeAsync(0) + expect(onBeforeQuit).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + } + ) + + it.runIf(process.platform === 'darwin')( + 'allows ordinary quitting after a refused install without requiring background servers to exit', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).not.toHaveBeenCalled() + // The main will-quit handler requests quit again after asynchronous teardown. + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + } + ) + + it.runIf(process.platform === 'darwin')( + 'prevents duplicate quits until asynchronous update cleanup finishes', + async () => { + vi.useFakeTimers() + let finishCleanup = (): void => {} + const onBeforeQuit = vi.fn( + () => + new Promise((resolve) => { + finishCleanup = resolve + }) + ) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { onBeforeQuit }) + await prepareStagedMacUpdate(downloadUpdate) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(onBeforeQuit).toHaveBeenCalledTimes(1) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + finishCleanup() + await vi.advanceTimersByTimeAsync(0) + const nativePreventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault: nativePreventDefault }) + expect(nativePreventDefault).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + } + ) + + it.runIf(process.platform === 'darwin')( + 'cannot use a previous refusal to bypass a pending retry check', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + let finishProbe = (_pids: number[]): void => {} + getMacUpdateRunningInstancesMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) + quitAndInstall() + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1000) + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + finishProbe([]) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce() + } + ) + + it.runIf(process.platform === 'darwin')( + 'permits ordinary shutdown after required update cleanup rejects', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const onBeforeQuit = vi.fn().mockRejectedValue(new Error('required cleanup failed')) + const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never, { + onBeforeQuit, + onBeforeQuitFailure: 'abort' + }) + await prepareStagedMacUpdate(downloadUpdate) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).not.toHaveBeenCalled() + expect(onBeforeQuit).toHaveBeenCalledOnce() + } + ) + + it.runIf(process.platform === 'darwin')( + 'keeps the install target and quit guard when checks or downloads are requested during a retry', + async () => { + vi.useFakeTimers() + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const { + setupAutoUpdater, + downloadUpdate, + quitAndInstall, + checkForUpdates, + checkForUpdatesFromMenu, + getUpdateStatus + } = await loadUpdaterModule() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The updater only reads webContents.send from this window fixture. + setupAutoUpdater({ webContents: { send: vi.fn() } } as never) + await prepareStagedMacUpdate(downloadUpdate) + getMacUpdateRunningInstancesMock.mockResolvedValue([9654]) + quitAndInstall() + await vi.advanceTimersByTimeAsync(1000) + let finishProbe = (_pids: number[]): void => {} + getMacUpdateRunningInstancesMock.mockImplementation( + () => + new Promise((resolve) => { + finishProbe = resolve + }) + ) + const failedStatus = getUpdateStatus() + const checks = autoUpdaterMock.checkForUpdates.mock.calls.length + const downloads = autoUpdaterMock.downloadUpdate.mock.calls.length + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + return Promise.resolve(undefined) + }) + const requestCompetingActions = (): void => { + checkForUpdatesFromMenu() + checkForUpdatesFromMenu({ localBuild: true }) + checkForUpdatesFromMenu({ channel: 'stable', targetTag: 'v1.0.70' }) + checkForUpdates() + downloadUpdate() + } + quitAndInstall() + requestCompetingActions() + await vi.advanceTimersByTimeAsync(1000) + requestCompetingActions() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(checks) + expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(downloads) + expect(getUpdateStatus()).toEqual(failedStatus) + const preventDefault = vi.fn() + appMock.emit('before-quit', { preventDefault }) + expect(preventDefault).toHaveBeenCalledOnce() + finishProbe([]) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/main/updater/updater-download-install.ts b/src/main/updater/updater-download-install.ts index 455e56e6efa..8b672eedbbd 100644 --- a/src/main/updater/updater-download-install.ts +++ b/src/main/updater/updater-download-install.ts @@ -1,4 +1,9 @@ -import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady } from '../updater-mac-install' +import { + beginMacUpdateDownload, + deferMacQuitUntilInstallerReady, + isMacInstallRequested, + setMacInstallPreflightInProgress +} from '../updater-mac-install' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { isExternallyManagedLinuxInstall } from '../linux-update-package-type' import { LINUX_PACKAGE_EXTERNALLY_MANAGED_MESSAGE } from '../linux-package-downloaded-status' @@ -12,7 +17,8 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress || this.pendingQuitAndInstallTimer || - this.quitAndInstallInProgress + this.quitAndInstallInProgress || + isMacInstallRequested() ) { return } @@ -20,6 +26,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { if (this.deferHeadlessServeInstall('install', this.getPendingInstallVersion())) { return } + // A queued check must not repoint the feed while native staging or installation is pending. + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() if ( deferMacQuitUntilInstallerReady( this.currentStatus, @@ -31,6 +40,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { return } + if (process.platform === 'darwin') { + setMacInstallPreflightInProgress(true) + } // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. this.pendingQuitAndInstallTimer = setTimeout(() => { void this.performQuitAndInstall() @@ -41,6 +53,9 @@ export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { if ( this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress || + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() || this.downloadInFlight ) { return diff --git a/src/main/updater/updater-install-execution.ts b/src/main/updater/updater-install-execution.ts index 257f8e4fa93..036fab4e119 100644 --- a/src/main/updater/updater-install-execution.ts +++ b/src/main/updater/updater-install-execution.ts @@ -2,7 +2,12 @@ import { BrowserWindow } from 'electron' import { killAllPty } from '../ipc/pty' import { withUpdaterSpan } from '../observability/instrumentation' import { runWithLaunchPath } from '../startup/hydrate-shell-path' -import { markMacQuitAndInstallInFlight, isMacInstallerReady } from '../updater-mac-install' +import { + markMacQuitAndInstallInFlight, + isMacInstallerReady, + setMacInstallPreflightInProgress +} from '../updater-mac-install' +import { getMacUpdateRunningInstances } from '../macos-update-running-instances' import { armUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' import { getLinuxPackageType } from '../linux-update-package-type' import { LINUX_PACKAGE_MARKER_UNUSABLE_MESSAGE } from '../linux-package-downloaded-status' @@ -51,14 +56,49 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { }) return } + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() this.quitAndInstallInProgress = true - markMacQuitAndInstallInFlight() - // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. this.quittingForUpdate = true try { + if (process.platform === 'darwin') { + setMacInstallPreflightInProgress(true) + let blockers: number[] + try { + blockers = await getMacUpdateRunningInstances() + } catch { + this.resetQuitForUpdateState() + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + this.sendInstallFailureStatus({ + state: 'error', + version: pendingVersion, + retryAction: 'install', + message: + 'Could not check for other running Orca instances. Orca remains open. Try again. If the check keeps failing, close the other Orca instances and background orca serve servers, then quit Orca to let the update install on exit. Reopen Orca afterwards.' + }) + recordUpdaterLifecycle('macos_running_instances_check_failed') + return + } + if (blockers.length > 0) { + this.resetQuitForUpdateState() + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + this.sendInstallFailureStatus({ + state: 'error', + version: pendingVersion, + retryAction: 'install', + message: `Close the other Orca instances (process IDs: ${blockers.slice(0, 10).join(', ')}) before installing this update. Background orca serve instances also need to stop. Orca remains open; retry the update after closing them.` + }) + recordUpdaterLifecycle('macos_install_blocked_by_running_instances', { + pids: blockers.slice(0, 10).join(', '), + count: blockers.length + }) + return + } + } + markMacQuitAndInstallInFlight() await withUpdaterSpan({ stage: 'install' }, async (span) => { span.setAttribute('updater.version', pendingVersion || 'unknown') span.setAttribute('updater.platform', process.platform) @@ -104,6 +144,7 @@ export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { return } // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. + setMacInstallPreflightInProgress(false) this.quitAndInstallNativeInvoked = true // Why: invoke before killAllPty/removing close listeners so a sync 'error' can recover while windows and PTYs are intact. const supervisorOwnsRelaunch = this.updateInstallMode === 'supervised-headless-serve' diff --git a/src/main/updater/updater-menu-checks.ts b/src/main/updater/updater-menu-checks.ts index 5e5294f29dc..f6710d555b0 100644 --- a/src/main/updater/updater-menu-checks.ts +++ b/src/main/updater/updater-menu-checks.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { is } from '@electron-toolkit/utils' +import { isMacInstallRequested } from '../updater-mac-install' import type { UpdateCheckOptions } from '../../shared/update-status-types' import type { ReleaseChannel } from '../../shared/release-channel' import { UpdaterScheduling } from './updater-scheduling' @@ -7,6 +8,13 @@ import { UpdaterScheduling } from './updater-scheduling' /** Handles checks initiated from the desktop menu and modifier-key variants. */ export abstract class UpdaterMenuChecks extends UpdaterScheduling { protected checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return + } if (!app.isPackaged || is.dev) { this.sendStatus({ state: 'not-available', userInitiated: true }) return @@ -60,6 +68,13 @@ export abstract class UpdaterMenuChecks extends UpdaterScheduling { const attemptId = this.beginUpdateCheckAttempt() const autoUpdater = this.getAutoUpdater() const launch = (): Promise | undefined => { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return undefined + } if (!this.isActiveUpdateCheckAttempt(attemptId)) { return undefined } diff --git a/src/main/updater/updater-scheduling.ts b/src/main/updater/updater-scheduling.ts index 954431385d0..70993c61ff2 100644 --- a/src/main/updater/updater-scheduling.ts +++ b/src/main/updater/updater-scheduling.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { is } from '@electron-toolkit/utils' +import { isMacInstallRequested } from '../updater-mac-install' import { withUpdaterSpan } from '../observability/instrumentation' import { AUTO_UPDATE_CHECK_INTERVAL_MS, @@ -45,6 +46,13 @@ export abstract class UpdaterScheduling extends UpdaterCheckFailure { protected runBackgroundUpdateCheck( nudgeId: string | null = this.getPersistedPendingUpdateNudgeId() ): boolean { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return false + } // Why: a pinned dev jump owns the feed until it settles; a background check would repoint it mid-flight and download the wrong build. if ( this.activeUpdateSource !== 'release' || @@ -69,6 +77,13 @@ export abstract class UpdaterScheduling extends UpdaterCheckFailure { const attemptId = this.beginUpdateCheckAttempt() const autoUpdater = this.getAutoUpdater() const launch = (): Promise | undefined => { + if ( + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + isMacInstallRequested() + ) { + return undefined + } if (!this.isActiveUpdateCheckAttempt(attemptId)) { return undefined } diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 9e5c5493f09..6a685f2f982 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -1,6 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' -import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' +import type { registerSshHandlers } from '../ipc/ssh' +import type { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' +import type { registerDaemonManagementHandlers } from '../ipc/pty-management' +import type { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' +import type { startFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' +import { + createMainWindowServiceStub, + createRuntime, + deferred, + type MainWindowStub +} from './main-window-service-stubs.test-fixture' const { onMock, @@ -17,6 +27,11 @@ const { setWorktreeCatalogRemoteClientNotifierMock, registerWorktreeHandlersMock, registerPtyHandlersMock, + registerSshHandlersMock, + registerRemoteWorkspaceHandlersMock, + registerDaemonManagementHandlersMock, + registerWorkspaceCleanupHandlersMock, + startFolderRepoGitUpgradeWatchMock, hydrateLocalPtyRegistryAtBootMock, setWorktreeBaseDirectoryWatcherSyncContextMock, scheduleWorktreeBaseDirectoryWatcherSyncMock, @@ -42,6 +57,12 @@ const { setWorktreeCatalogRemoteClientNotifierMock: vi.fn(), registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), + registerSshHandlersMock: vi.fn<(...args: Parameters) => void>(), + registerRemoteWorkspaceHandlersMock: + vi.fn<(...args: Parameters) => void>(), + registerDaemonManagementHandlersMock: vi.fn(), + registerWorkspaceCleanupHandlersMock: vi.fn(), + startFolderRepoGitUpgradeWatchMock: vi.fn(), hydrateLocalPtyRegistryAtBootMock: vi.fn(), setWorktreeBaseDirectoryWatcherSyncContextMock: vi.fn(), scheduleWorktreeBaseDirectoryWatcherSyncMock: vi.fn(), @@ -99,6 +120,20 @@ vi.mock('../ipc/pty', () => ({ registerPtyHandlers: registerPtyHandlersMock })) +vi.mock('../ipc/ssh', () => ({ registerSshHandlers: registerSshHandlersMock })) +vi.mock('../ipc/remote-workspace', () => ({ + registerRemoteWorkspaceHandlers: registerRemoteWorkspaceHandlersMock +})) +vi.mock('../ipc/pty-management', () => ({ + registerDaemonManagementHandlers: registerDaemonManagementHandlersMock +})) +vi.mock('../ipc/workspace-cleanup', () => ({ + registerWorkspaceCleanupHandlers: registerWorkspaceCleanupHandlersMock +})) +vi.mock('../ipc/folder-repo-git-upgrade', () => ({ + startFolderRepoGitUpgradeWatch: startFolderRepoGitUpgradeWatchMock +})) + vi.mock('../memory/hydrate-local-pty-registry', () => ({ hydrateLocalPtyRegistryAtBoot: hydrateLocalPtyRegistryAtBootMock })) @@ -133,57 +168,16 @@ import { attachMainWindowServices } from './attach-main-window-services' type MockFn = ReturnType -type MainWindowStub = { - id?: number - isDestroyed?: MockFn - on: MockFn - once: MockFn - webContents: { - id?: number - getURL: MockFn - isDestroyed?: MockFn - isLoadingMainFrame: MockFn - on: MockFn - send?: MockFn - reload?: MockFn - session: { - setPermissionRequestHandler: MockFn - setPermissionCheckHandler: MockFn - } - } -} - -type RuntimeStub = { - attachWindow: MockFn - setNotifier: ReturnType void>> - markRendererReloading: MockFn - markRendererReloadCancelled: MockFn - markGraphReloadFailed: MockFn - markGraphUnavailable: MockFn -} - function createMainWindow( extraWebContents: { isLoadingMainFrame?: MockFn; on?: MockFn; send?: MockFn } = {} ): MainWindowStub { - return { - id: 1, - isDestroyed: vi.fn(() => false), - on: vi.fn(), - once: vi.fn(), - webContents: { - id: 1, - getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), - isDestroyed: vi.fn(() => false), - isLoadingMainFrame: vi.fn(() => true), - on: vi.fn(), - reload: vi.fn(), - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock, - setPermissionCheckHandler: setPermissionCheckHandlerMock - }, - ...extraWebContents - } - } + return createMainWindowServiceStub( + { + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock + }, + extraWebContents + ) } function createStore(): Store & { flushPendingAsync: MockFn } { @@ -193,25 +187,6 @@ function createStore(): Store & { flushPendingAsync: MockFn } { } as unknown as Store & { flushPendingAsync: MockFn } } -function createRuntime(): RuntimeStub { - return { - attachWindow: vi.fn(), - setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), - markRendererReloading: vi.fn(), - markRendererReloadCancelled: vi.fn(), - markGraphReloadFailed: vi.fn(), - markGraphUnavailable: vi.fn() - } -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} - function getClosedHandlers(mainWindowOnMock: MockFn): (() => void)[] { return mainWindowOnMock.mock.calls .filter(([event]) => event === 'closed') @@ -239,11 +214,30 @@ describe('attachMainWindowServices', () => { it('gives host-local catalog notifiers the runtime', () => { const runtime = createRuntime() + const mainWindow = createMainWindow() + const store = createStore() - attachMainWindowServices(createMainWindow() as never, createStore(), runtime as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Stubs provide the window/runtime methods exercised by attachment. + attachMainWindowServices(mainWindow as never, store, runtime as never) expect(setRepoRemoteClientNotifierMock).toHaveBeenCalledWith(runtime) expect(setWorktreeCatalogRemoteClientNotifierMock).toHaveBeenCalledWith(runtime) + expect(registerSshHandlersMock).toHaveBeenCalledExactlyOnceWith( + store, + expect.any(Function), + runtime + ) + expect(registerSshHandlersMock.mock.calls[0]?.[1]()).toBe(mainWindow) + expect(registerRemoteWorkspaceHandlersMock).toHaveBeenCalledExactlyOnceWith( + store, + expect.any(Function), + runtime + ) + expect(registerRemoteWorkspaceHandlersMock.mock.calls[0]?.[1]()).toBe(mainWindow) + expect(registerDaemonManagementHandlersMock).toHaveBeenCalledExactlyOnceWith() + expect(registerDaemonManagementHandlersMock).toHaveBeenCalledAfter(registerPtyHandlersMock) + expect(registerWorkspaceCleanupHandlersMock).toHaveBeenCalledExactlyOnceWith(store) + expect(startFolderRepoGitUpgradeWatchMock).toHaveBeenCalledExactlyOnceWith(store, mainWindow) }) it('reloads the app renderer through main and marks expected renderer teardown', async () => { diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 7bd3e41b378..b448681857e 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -26,6 +26,8 @@ import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/brows import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import type { PreQuitCleanupFailureMode, UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' +import { openCodeHookService, openCode2HookService } from '../opencode/hook-service' +import { listLiveDaemonPtyIds } from '../daemon/daemon-provider-state' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' @@ -107,6 +109,8 @@ export function attachMainWindowServices( scheduleHistoryGc(async () => { return getKnownWorktreeIdsForHistoryGc(store) }) + openCodeHookService.configDirGc.schedule(listLiveDaemonPtyIds) + openCode2HookService.configDirGc.schedule(listLiveDaemonPtyIds) const localPtyProviderStartupReady = options?.awaitLocalPtyProviderStartup?.() if (localPtyProviderStartupReady) { void localPtyProviderStartupReady diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 960379a797d..791133de126 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -329,6 +329,33 @@ describe('createOrFocusDashboardPopout', () => { } }) + it('keeps saving bounds after a vetoed quit and freezes them only on an allowed quit', () => { + vi.useFakeTimers() + try { + const store = makeStore() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The window fixture reads getUI, updateUI, and onUIChanged supplied by this store. + createOrFocusDashboardPopout(store as never) + const win = instances[0] + const freeze = appOnMock.mock.calls.find(([event]) => event === 'before-quit')?.[1] + expect(freeze).toBeTypeOf('function') + freeze({ defaultPrevented: true }) + win.bounds = { x: 10, y: 20, width: 1200, height: 900 } + win.emit('resize') + vi.advanceTimersByTime(500) + expect(store.updateUI).toHaveBeenCalledWith({ + dashboardPopoutBounds: { x: 10, y: 20, width: 1200, height: 900 } + }) + + store.updateUI.mockClear() + freeze({ defaultPrevented: false }) + win.emit('resize') + vi.advanceTimersByTime(500) + expect(store.updateUI).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('closeDashboardPopout closes an open window', () => { createOrFocusDashboardPopout(makeStore() as never) const win = instances[0] diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index a6b082ac1ae..15e4964a46f 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, nativeTheme, type WebContents } from 'electron' +import { app, BrowserWindow, nativeTheme, type WebContents, type Event } from 'electron' import { join } from 'node:path' import { is } from '@electron-toolkit/utils' import type { Store } from '../persistence' @@ -264,7 +264,10 @@ export function createOrFocusDashboardPopout( window.on('resize', saveBounds) window.on('move', saveBounds) - const freezeBounds = (): void => { + const freezeBounds = (event?: Event): void => { + if (event?.defaultPrevented) { + return + } windowClosing = true if (boundsTimer) { clearTimeout(boundsTimer) diff --git a/src/main/window/main-window-service-stubs.test-fixture.ts b/src/main/window/main-window-service-stubs.test-fixture.ts new file mode 100644 index 00000000000..2db0cbcc762 --- /dev/null +++ b/src/main/window/main-window-service-stubs.test-fixture.ts @@ -0,0 +1,80 @@ +import { vi } from 'vitest' +import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' + +type MockFn = ReturnType + +export type MainWindowStub = { + id?: number + isDestroyed?: MockFn + on: MockFn + once: MockFn + webContents: { + id?: number + getURL: MockFn + isDestroyed?: MockFn + isLoadingMainFrame: MockFn + on: MockFn + send?: MockFn + reload?: MockFn + session: { + setPermissionRequestHandler: MockFn + setPermissionCheckHandler: MockFn + } + } +} + +type RuntimeStub = { + attachWindow: MockFn + setNotifier: ReturnType void>> + markRendererReloading: MockFn + markRendererReloadCancelled: MockFn + markGraphReloadFailed: MockFn + markGraphUnavailable: MockFn +} + +export function createMainWindowServiceStub( + permissionHandlers: { + setPermissionRequestHandler: MockFn + setPermissionCheckHandler: MockFn + }, + extraWebContents: { isLoadingMainFrame?: MockFn; on?: MockFn; send?: MockFn } = {} +): MainWindowStub { + return { + id: 1, + isDestroyed: vi.fn(() => false), + on: vi.fn(), + once: vi.fn(), + webContents: { + id: 1, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isDestroyed: vi.fn(() => false), + isLoadingMainFrame: vi.fn(() => true), + on: vi.fn(), + reload: vi.fn(), + session: { + setPermissionRequestHandler: permissionHandlers.setPermissionRequestHandler, + setPermissionCheckHandler: permissionHandlers.setPermissionCheckHandler + }, + ...extraWebContents + } + } +} + +export function createRuntime(): RuntimeStub { + return { + attachWindow: vi.fn(), + setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), + markRendererReloading: vi.fn(), + markRendererReloadCancelled: vi.fn(), + markGraphReloadFailed: vi.fn(), + markGraphUnavailable: vi.fn() + } +} + +export function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} diff --git a/src/main/window/main-window-state-lifecycle.test.ts b/src/main/window/main-window-state-lifecycle.test.ts new file mode 100644 index 00000000000..3015481033f --- /dev/null +++ b/src/main/window/main-window-state-lifecycle.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const { appMock } = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + return { appMock: new EventEmitter() } +}) +vi.mock('electron', () => ({ app: appMock })) +vi.mock('./foreground-activation-policy', () => ({ + isWindowlessLaunch: () => true, + showWindowWithoutStealingFocus: vi.fn() +})) +vi.mock('./main-window-visual-lifecycle', () => ({ + MIN_WIDTH: 480, + MIN_HEIGHT: 360, + syncTrafficLightPosition: vi.fn() +})) + +import { installMainWindowStateLifecycle } from './main-window-state-lifecycle' + +beforeEach(() => { + vi.useFakeTimers() + appMock.removeAllListeners() +}) +afterEach(() => vi.useRealTimers()) + +it('continues saving bounds after an updater quit veto and freezes them on allowed quit', async () => { + const mainWindow = Object.assign(new EventEmitter(), { + webContents: Object.assign(new EventEmitter(), { + send: vi.fn(), + setZoomLevel: vi.fn() + }), + isDestroyed: () => false, + isFullScreen: () => false, + isMaximized: () => false, + getBounds: () => ({ x: 0, y: 0, width: 1200, height: 800 }) + }) + const updateUI = vi.fn() + const lifecycle = installMainWindowStateLifecycle({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture implements the window members read by the bounds lifecycle. + mainWindow: mainWindow as never, + revealOnDidFinishLoad: false, + savedMaximized: false, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Bounds persistence reads only updateUI from this store fixture. + store: { updateUI } as never + }) + appMock.emit('before-quit', { defaultPrevented: true }) + expect(lifecycle.isWindowClosing()).toBe(false) + mainWindow.emit('resize') + await vi.advanceTimersByTimeAsync(500) + expect(updateUI).toHaveBeenCalledWith({ + windowMaximized: false, + windowBounds: { x: 0, y: 0, width: 1200, height: 800 } + }) + + updateUI.mockClear() + appMock.emit('before-quit', { defaultPrevented: false }) + expect(lifecycle.isWindowClosing()).toBe(true) + mainWindow.emit('resize') + await vi.advanceTimersByTimeAsync(500) + expect(updateUI).not.toHaveBeenCalled() + lifecycle.clearInitialRevealFallbackTimer() + lifecycle.dispose() +}) diff --git a/src/main/window/main-window-state-lifecycle.ts b/src/main/window/main-window-state-lifecycle.ts index 2a5345bef50..21ce1b54bd3 100644 --- a/src/main/window/main-window-state-lifecycle.ts +++ b/src/main/window/main-window-state-lifecycle.ts @@ -1,4 +1,4 @@ -import { app, type BrowserWindow } from 'electron' +import { app, type BrowserWindow, type Event } from 'electron' import type { Store } from '../persistence' import { uiZoomFactorFromLevel } from '../../shared/ui-zoom-level' import { isWindowlessLaunch, showWindowWithoutStealingFocus } from './foreground-activation-policy' @@ -7,7 +7,7 @@ import { MIN_HEIGHT, MIN_WIDTH, syncTrafficLightPosition } from './main-window-v export type MainWindowStateLifecycle = { clearInitialRevealFallbackTimer: () => void dispose: () => void - freezeBoundsOnQuit: () => void + freezeBoundsOnQuit: (event?: Event) => void isWindowClosing: () => boolean resumeBoundsPersistence: () => void } @@ -105,7 +105,10 @@ export function installMainWindowStateLifecycle(args: { mainWindow.on('move', saveBounds) // Why: the auto-updater calls removeAllListeners('close') before quitting, so latch on app 'before-quit' too to freeze bounds during teardown. - const freezeBoundsOnQuit = (): void => { + const freezeBoundsOnQuit = (event?: Event): void => { + if (event?.defaultPrevented) { + return + } windowClosing = true if (boundsTimer) { clearTimeout(boundsTimer) diff --git a/src/main/window/main-window-updater-deferred-setup.test.ts b/src/main/window/main-window-updater-deferred-setup.test.ts new file mode 100644 index 00000000000..f104b9ea138 --- /dev/null +++ b/src/main/window/main-window-updater-deferred-setup.test.ts @@ -0,0 +1,426 @@ +import { EventEmitter } from 'node:events' +import type { BrowserWindow } from 'electron' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { UpdaterSetupOptions } from '../updater' + +const { setupMock, milestoneMock } = vi.hoisted(() => ({ + setupMock: vi.fn<(window: BrowserWindow, options?: UpdaterSetupOptions) => void>(), + milestoneMock: vi.fn<(name: string) => void>() +})) + +vi.mock('electron', () => ({ app: {}, ipcMain: {} })) +vi.mock('../updater', () => ({ setupAutoUpdater: setupMock })) +vi.mock('../ipc/ui', () => ({ isTrustedUIRenderer: () => true })) +vi.mock('../startup/startup-diagnostics', () => ({ logStartupMilestone: milestoneMock })) + +function createWindowOwner() { + const window = Object.assign(new EventEmitter(), { + id: 7, + isDestroyed: vi.fn(() => false) + }) + const ui = { + lastUpdateCheckAt: 123, + pendingUpdateNudgeId: 'pending', + dismissedUpdateNudgeId: 'dismissed', + releaseChannelOverride: 'rc' + } satisfies Partial> + const store = { + getUI: vi.fn(() => ui), + updateUI: vi.fn<(patch: Parameters[0]) => void>(), + flushPendingAsync: vi.fn().mockResolvedValue(undefined) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This owner only reads id/isDestroyed and registers once; EventEmitter supplies the original once contract. + const mainWindow = window as unknown as BrowserWindow + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The complete owner accesses only these three store methods and the checked UI fields above. + const persistence = store as unknown as Store + return { window, mainWindow, store, persistence } +} + +function captureError(action: () => void): unknown { + try { + action() + } catch (error) { + return error + } + throw new Error('Expected action to throw') +} + +function capturedSetupOptions(): UpdaterSetupOptions { + const options = setupMock.mock.calls.at(-1)?.[1] + if (!options) { + throw new Error('Missing updater options') + } + return options +} + +describe('completed deferred updater setup ownership', () => { + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + setupMock.mockReset() + milestoneMock.mockReset() + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('releases all 64 completed fallback handles without repeating initialization', async () => { + const updater = await import('./main-window-updater') + for (let index = 0; index < 64; index++) { + const owner = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + expect(vi.getTimerCount()).toBe(1) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenLastCalledWith(owner.mainWindow, expect.any(Object)) + expect(milestoneMock).toHaveBeenLastCalledWith('updater-setup-done') + expect(vi.getTimerCount()).toBe(0) + } + updater.ensureAutoUpdaterConfigured() + await vi.advanceTimersByTimeAsync(15_000) + expect(setupMock).toHaveBeenCalledTimes(64) + expect(milestoneMock).toHaveBeenCalledTimes(64) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not retain the expired native fallback of a destroyed window', async () => { + vi.useRealTimers() + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + owner.window.isDestroyed.mockReturnValue(true) + function scheduleNativeFallback(): WeakRef { + const nativeSetTimeout = globalThis.setTimeout + const armed = vi + .spyOn(globalThis, 'setTimeout') + .mockImplementation((callback, delay, ...args) => + nativeSetTimeout(callback, delay === 15_000 ? 1 : delay, ...args) + ) + try { + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + expect(armed).toHaveBeenCalledOnce() + expect(armed.mock.calls[0]?.[1]).toBe(15_000) + const handle = armed.mock.results[0]?.value + if (!handle) { + throw new Error('Missing native fallback timer') + } + expect(handle.hasRef()).toBe(false) + return new WeakRef(handle) + } finally { + armed.mockClear() + armed.mockRestore() + } + } + // The real deadline has separate paired proof; only this fixture's native wait is shortened. + const weakTimer = scheduleNativeFallback() + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(owner.window.isDestroyed).toHaveBeenCalledOnce() + updater.ensureAutoUpdaterConfigured() + expect(owner.window.isDestroyed).toHaveBeenCalledTimes(2) + expect(setupMock).not.toHaveBeenCalled() + expect(milestoneMock).not.toHaveBeenCalled() + expect(globalThis.gc).toBeTypeOf('function') + for (let turn = 0; turn < 4; turn++) { + await new Promise((resolve) => setImmediate(resolve)) + globalThis.gc?.() + } + expect(weakTimer.deref()).toBeUndefined() + }) + + it('keeps setup after ready-to-show and releases its fallback afterward', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + owner.window.emit('ready-to-show') + expect(setupMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(2) + await vi.advanceTimersByTimeAsync(0) + expect(setupMock).toHaveBeenCalledOnce() + expect(milestoneMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + owner.window.emit('ready-to-show') + updater.ensureAutoUpdaterConfigured() + await vi.advanceTimersByTimeAsync(15_000) + expect(setupMock).toHaveBeenCalledOnce() + }) + + it('preserves the 15-second fallback when first paint never arrives', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + await vi.advanceTimersByTimeAsync(14_999) + expect(setupMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(setupMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + owner.window.emit('ready-to-show') + await vi.advanceTimersByTimeAsync(0) + expect(setupMock).toHaveBeenCalledOnce() + }) + + it('preserves destroyed-window guard reads before and at the fallback deadline', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + owner.window.isDestroyed.mockReturnValue(true) + updater.ensureAutoUpdaterConfigured() + expect(owner.window.isDestroyed).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(14_999) + expect(owner.window.isDestroyed).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(owner.window.isDestroyed).toHaveBeenCalledTimes(2) + updater.ensureAutoUpdaterConfigured() + expect(owner.window.isDestroyed).toHaveBeenCalledTimes(3) + expect(setupMock).not.toHaveBeenCalled() + }) + + it('keeps the fallback guard when destruction happens after ready but before immediate', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + owner.window.emit('ready-to-show') + owner.window.isDestroyed.mockReturnValue(true) + await vi.advanceTimersByTimeAsync(0) + expect(owner.window.isDestroyed).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(15_000) + expect(owner.window.isDestroyed).toHaveBeenCalledTimes(2) + expect(setupMock).not.toHaveBeenCalled() + }) + + it('retains original setup order and avoids reentrant initialization', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const trace: string[] = [] + setupMock.mockImplementation(() => { + trace.push('setup') + updater.ensureAutoUpdaterConfigured() + trace.push('setup-return') + }) + milestoneMock.mockImplementation(() => { + trace.push('milestone') + updater.ensureAutoUpdaterConfigured() + }) + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + updater.ensureAutoUpdaterConfigured() + expect(trace).toEqual(['setup', 'setup-return', 'milestone']) + expect(setupMock).toHaveBeenCalledOnce() + expect(owner.window.isDestroyed).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['setup', 'milestone'] as const)( + 'keeps the exact %s error and releases its completed fallback', + async (failure) => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const error = new Error(`${failure} failed`) + if (failure === 'setup') { + setupMock.mockImplementation(() => { + throw error + }) + } else { + milestoneMock.mockImplementation(() => { + throw error + }) + } + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + expect(captureError(() => updater.ensureAutoUpdaterConfigured())).toBe(error) + expect(setupMock).toHaveBeenCalledOnce() + expect(milestoneMock).toHaveBeenCalledTimes(failure === 'setup' ? 0 : 1) + expect(vi.getTimerCount()).toBe(0) + updater.ensureAutoUpdaterConfigured() + await vi.advanceTimersByTimeAsync(15_000) + expect(setupMock).toHaveBeenCalledOnce() + } + ) + + it.each(['setup', 'milestone'] as const)( + 'keeps the exact fallback %s error without retrying initialization', + async (failure) => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const error = new Error(`${failure} fallback failed`) + if (failure === 'setup') { + setupMock.mockImplementation(() => { + throw error + }) + } else { + milestoneMock.mockImplementation(() => { + throw error + }) + } + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + await expect(vi.advanceTimersByTimeAsync(15_000)).rejects.toBe(error) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + } + ) + + it('preserves pending retry after a failed destroyed-state read', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const error = new Error('destroyed-state read failed') + owner.window.isDestroyed.mockImplementationOnce(() => { + throw error + }) + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + expect(captureError(() => updater.ensureAutoUpdaterConfigured())).toBe(error) + expect(setupMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('preserves pending manual setup after failed ready registration', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const error = new Error('ready registration failed') + vi.spyOn(owner.window, 'once').mockImplementationOnce(() => { + throw error + }) + expect( + captureError(() => + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + ) + ).toBe(error) + expect(vi.getTimerCount()).toBe(0) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenCalledOnce() + }) + + it('releases the fallback created after synchronous registration reentry', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const once = owner.window.once.bind(owner.window) + vi.spyOn(owner.window, 'once').mockImplementationOnce((event, listener) => { + updater.ensureAutoUpdaterConfigured() + return once(event, listener) + }) + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence) + expect(setupMock).toHaveBeenCalledOnce() + expect(milestoneMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + owner.window.emit('ready-to-show') + await vi.advanceTimersByTimeAsync(0) + expect(setupMock).toHaveBeenCalledOnce() + }) + + it('keeps a same-ID successor pending after the old window becomes ready', async () => { + const updater = await import('./main-window-updater') + const old = createWindowOwner() + const successor = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(old.mainWindow, old.persistence) + updater.scheduleMainWindowAutoUpdaterSetup(successor.mainWindow, successor.persistence) + expect(vi.getTimerCount()).toBe(2) + old.window.emit('ready-to-show') + await vi.advanceTimersByTimeAsync(0) + expect(setupMock).toHaveBeenLastCalledWith(old.mainWindow, expect.any(Object)) + expect(vi.getTimerCount()).toBe(1) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenLastCalledWith(successor.mainWindow, expect.any(Object)) + expect(setupMock).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['setup', 'milestone'] as const)( + 'preserves a reentrant successor when the old %s throws', + async (failure) => { + const updater = await import('./main-window-updater') + const old = createWindowOwner() + const successor = createWindowOwner() + const error = new Error('old owner failed after replacement') + const replaceAndThrow = () => { + updater.scheduleMainWindowAutoUpdaterSetup(successor.mainWindow, successor.persistence) + throw error + } + if (failure === 'setup') { + setupMock.mockImplementationOnce(replaceAndThrow) + } else { + milestoneMock.mockImplementationOnce(replaceAndThrow) + } + updater.scheduleMainWindowAutoUpdaterSetup(old.mainWindow, old.persistence) + expect(captureError(() => updater.ensureAutoUpdaterConfigured())).toBe(error) + expect(vi.getTimerCount()).toBe(1) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenLastCalledWith(successor.mainWindow, expect.any(Object)) + expect(setupMock).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + } + ) + + it('preserves the successor after an old destroyed fallback', async () => { + const updater = await import('./main-window-updater') + const old = createWindowOwner() + const successor = createWindowOwner() + updater.scheduleMainWindowAutoUpdaterSetup(old.mainWindow, old.persistence) + old.window.isDestroyed.mockReturnValue(true) + await vi.advanceTimersByTimeAsync(5_000) + updater.scheduleMainWindowAutoUpdaterSetup(successor.mainWindow, successor.persistence) + await vi.advanceTimersByTimeAsync(10_000) + expect(setupMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + updater.ensureAutoUpdaterConfigured() + expect(setupMock).toHaveBeenCalledWith(successor.mainWindow, expect.any(Object)) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps setup options, live store reads, nudge writes and required quit flush', async () => { + const updater = await import('./main-window-updater') + const owner = createWindowOwner() + const trace: string[] = [] + const error = new Error('required cleanup failed') + owner.store.flushPendingAsync.mockImplementation(async () => { + trace.push('flush') + }) + updater.scheduleMainWindowAutoUpdaterSetup(owner.mainWindow, owner.persistence, { + updateInstallMode: 'supervised-headless-serve', + onBeforeUpdateQuitFailure: 'abort', + onBeforeUpdateQuit: () => { + trace.push('cleanup') + throw error + } + }) + updater.ensureAutoUpdaterConfigured() + const options = capturedSetupOptions() + expect(Object.keys(options)).toEqual([ + 'getLastUpdateCheckAt', + 'onBeforeQuit', + 'setLastUpdateCheckAt', + 'getPendingUpdateNudgeId', + 'getDismissedUpdateNudgeId', + 'setPendingUpdateNudgeId', + 'setDismissedUpdateNudgeId', + 'getReleaseChannelOverride', + 'onBeforeQuitFailure', + 'installMode' + ]) + expect(options.getLastUpdateCheckAt?.()).toBe(123) + expect(options.getPendingUpdateNudgeId?.()).toBe('pending') + expect(options.getDismissedUpdateNudgeId?.()).toBe('dismissed') + expect(options.getReleaseChannelOverride?.()).toBe('rc') + owner.store.getUI.mockReturnValue({ ...owner.store.getUI(), lastUpdateCheckAt: 456 }) + expect(options.getLastUpdateCheckAt?.()).toBe(456) + options.setLastUpdateCheckAt?.(789) + options.setPendingUpdateNudgeId?.('next') + options.setPendingUpdateNudgeId?.(null) + options.setDismissedUpdateNudgeId?.('dismiss') + expect(owner.store.updateUI.mock.calls).toEqual([ + [{ lastUpdateCheckAt: 789 }], + [{ pendingUpdateNudgeId: 'next', dismissedUpdateVersion: null }], + [{ pendingUpdateNudgeId: null }], + [{ dismissedUpdateNudgeId: 'dismiss' }] + ]) + expect(options.installMode).toBe('supervised-headless-serve') + expect(options.onBeforeQuitFailure).toBe('abort') + await expect(options.onBeforeQuit?.()).rejects.toBe(error) + expect(trace).toEqual(['cleanup', 'flush']) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/window/main-window-updater.ts b/src/main/window/main-window-updater.ts index 7cdcdd8cd73..8719bbe51ad 100644 --- a/src/main/window/main-window-updater.ts +++ b/src/main/window/main-window-updater.ts @@ -40,46 +40,66 @@ export function scheduleMainWindowAutoUpdaterSetup( ): void { // Why: setupAutoUpdater sync-require()s electron-updater (slow on cold Windows w/ Defender, #7225), so defer past first paint; timer fallback covers crash-looping renderers. let updaterSetupDone = false + let updaterSetupFallback: ReturnType | null = null + const clearDeferredSetup = (): void => { + if (updaterSetupFallback) { + clearTimeout(updaterSetupFallback) + updaterSetupFallback = null + } + if (pendingAutoUpdaterSetup === setupAutoUpdaterDeferred) { + pendingAutoUpdaterSetup = null + } + } const setupAutoUpdaterDeferred = (): void => { if (updaterSetupDone || mainWindow.isDestroyed()) { return } updaterSetupDone = true - setupAutoUpdater(mainWindow, { - getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt, - onBeforeQuit: async () => { - try { - await options?.onBeforeUpdateQuit?.() - } finally { - await store.flushPendingAsync() - } - }, - setLastUpdateCheckAt: (timestamp) => { - store.updateUI({ lastUpdateCheckAt: timestamp }) - }, - getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null, - getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null, - setPendingUpdateNudgeId: (id) => { - // Why: only the apply branch also nulls dismissedUpdateVersion so relaunch can't resurrect the old hidden card; clearing must not, or it un-dismisses. - if (id) { - store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null }) - } else { - store.updateUI({ pendingUpdateNudgeId: null }) - } - }, - setDismissedUpdateNudgeId: (id) => { - store.updateUI({ dismissedUpdateNudgeId: id }) - }, - getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null, - onBeforeQuitFailure: options?.onBeforeUpdateQuitFailure, - installMode: options?.updateInstallMode - }) - logStartupMilestone('updater-setup-done') + try { + setupAutoUpdater(mainWindow, { + getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt, + onBeforeQuit: async () => { + try { + await options?.onBeforeUpdateQuit?.() + } finally { + await store.flushPendingAsync() + } + }, + setLastUpdateCheckAt: (timestamp) => { + store.updateUI({ lastUpdateCheckAt: timestamp }) + }, + getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null, + getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null, + setPendingUpdateNudgeId: (id) => { + // Why: only the apply branch also nulls dismissedUpdateVersion so relaunch can't resurrect the old hidden card; clearing must not, or it un-dismisses. + if (id) { + store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null }) + } else { + store.updateUI({ pendingUpdateNudgeId: null }) + } + }, + setDismissedUpdateNudgeId: (id) => { + store.updateUI({ dismissedUpdateNudgeId: id }) + }, + getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null, + onBeforeQuitFailure: options?.onBeforeUpdateQuitFailure, + installMode: options?.updateInstallMode + }) + logStartupMilestone('updater-setup-done') + } finally { + clearDeferredSetup() + } } pendingAutoUpdaterSetup = setupAutoUpdaterDeferred mainWindow.once('ready-to-show', () => setImmediate(setupAutoUpdaterDeferred)) - const updaterSetupFallback = setTimeout(setupAutoUpdaterDeferred, UPDATER_SETUP_FALLBACK_MS) + updaterSetupFallback = setTimeout(() => { + updaterSetupFallback = null + setupAutoUpdaterDeferred() + }, UPDATER_SETUP_FALLBACK_MS) updaterSetupFallback.unref?.() + if (updaterSetupDone) { + clearDeferredSetup() + } } export function registerUpdaterHandlers(_store: Store): void { diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts new file mode 100644 index 00000000000..48246e10573 --- /dev/null +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -0,0 +1,281 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' +import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' +import { getZshShellReadyWrapperFile } from './providers/local-pty-shell-ready-wrapper-generation' +import { encodeShellStartupFeatures, selectShellStartupFeatures } from './shell-startup-features' +import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' +import { hasZsh, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' + +const itWithZsh = hasZsh ? it : it.skip +const USER_WIDGET = `orca_test_line_init() { + O_UC=$((\${O_UC:-0}+1)) + O_UN="$WIDGET" + builtin printf 'ORCA_TEST_USER_WIDGET_CALL\\n' + return 1 +} +zle -N zle-line-init orca_test_line_init +` +const USER_REDRAW = `orca_test_redraw() { + O_RC=$((\${O_RC:-0}+1)) + O_RN="$WIDGET" + return 1 +} +zle -N zle-line-pre-redraw orca_test_redraw +` +const USER_PRECMD = `precmd() { + O_PCALLS=$((\${O_PCALLS:-0}+1)) + O_PN="$0" + O_PO="\${O_PO:-\${options[ksharrays]}:\${options[nounset]}}" + return 1 +} +` + +describe('zsh deferred startup after prompt-hook replacement', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + itWithZsh.each([ + 'history', + 'startup', + 'chained-startup', + 'stock-history', + 'stock-startup', + 'stock-chained-redraw', + 'stock-nonzero-precmd', + 'ordered-startup', + 'replaced-precmd-startup', + 'status-startup', + 'sticky-startup', + 'scheduled-startup', + 'unavailable-startup', + 'repeat-history', + 'repeat-startup' + ] as const)( + 'restores CLI precedence and preserves the user widget in a %s pane', + async (intent) => { + const historyOnly = intent.endsWith('history') + const repeatSource = intent.startsWith('repeat-') + const stockBinding = intent.startsWith('stock-') + const chainedLineInit = intent === 'chained-startup' + const chainedRedraw = intent === 'stock-chained-redraw' + const nonzeroPrecmd = intent === 'stock-nonzero-precmd' + const orderedPrecmd = intent === 'ordered-startup' + const replacedPrecmd = intent === 'replaced-precmd-startup' + const statusPrecmd = intent === 'status-startup' + const stickyPrecmd = intent === 'sticky-startup' + const scheduledPrecmd = intent === 'scheduled-startup' + const unavailableSched = intent === 'unavailable-startup' + const scheduleCleanup = scheduledPrecmd || unavailableSched || orderedPrecmd + const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-')) + roots.push(home) + const cliBin = join(home, 'cli', 'bin') + const ambientBin = join(home, 'ambient-bin') + const wrapperDir = join(home, 'wrapper') + for (const bin of [cliBin, ambientBin, wrapperDir]) { + mkdirSync(bin, { recursive: true }) + } + for (const bin of [cliBin, ambientBin]) { + writeFileSync(join(bin, 'orca-dev'), '#!/bin/sh\nexit 0\n') + chmodSync(join(bin, 'orca-dev'), 0o755) + } + writeFileSync( + join(home, '.zshenv'), + (chainedLineInit || chainedRedraw || repeatSource + ? '' + : stockBinding + ? USER_REDRAW + : USER_WIDGET) + + (statusPrecmd + ? 'precmd() { O_FIRST_IN=${O_FIRST_IN:-$?}; }\n' + : stickyPrecmd + ? `emulate sh -c 'precmd() { O_FIRST_IN="\${O_FIRST_IN:-$?:\${options[shwordsplit]}:\${options[ksharrays]}}"; return 1; }'\n` + : scheduledPrecmd + ? 'zmodload zsh/sched\nO_EVENT() { O_EO=${_orca_deferred_init_done:-0}; }\nsched +0 O_EVENT\nsched +3600 O_EVENT\n' + : unavailableSched + ? 'zmodload zsh/zleparameter zsh/terminfo\nO_MP=("${module_path[@]}"); module_path=()\n' + : nonzeroPrecmd || replacedPrecmd + ? USER_PRECMD + : orderedPrecmd + ? USER_PRECMD.replace('return 1', 'return 0') + : '') + ) + if (statusPrecmd || stickyPrecmd) { + writeFileSync( + join(home, '.zlogin'), + 'orca_test_status() { return 42; }; orca_test_status\n' + ) + } + // Replay Ubuntu's later widget binding before the user's own startup changes. + const stockWidget = + stockBinding || stickyPrecmd || scheduledPrecmd + ? USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init') + : '' + writeFileSync( + join(home, '.zshrc'), + `${statusPrecmd || stickyPrecmd ? 'PS1="ORCA_FIRST_PROMPT:%? "\n' : ''}${repeatSource ? USER_WIDGET : ''}${unavailableSched ? 'module_path=("${O_MP[@]}")\n' : ''}${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ + chainedLineInit + ? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n` + : chainedRedraw + ? `${USER_REDRAW.replace('zle -N zle-line-pre-redraw orca_test_redraw', 'zle -N orca_test_redraw')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-pre-redraw orca_test_redraw\n` + : '' + }${nonzeroPrecmd ? 'orca_test_array() { O_AC=called; }\nprecmd_functions=(orca_test_array)\nsetopt KSH_ARRAYS NO_UNSET\n' : orderedPrecmd ? 'orca_test_array() { O_AO=${O_AO:-${_orca_deferred_init_done:-0}}; }\nprecmd_functions=(orca_test_array "${precmd_functions[@]}")\n' : replacedPrecmd ? 'precmd() { O_NPC=$((${O_NPC:-0}+1)); O_NPN="$0"; }\n' : ''}` + ) + writeFileSync(join(wrapperDir, '.zshenv'), getZshShellReadyWrapperFile()) + writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '') + const env: Record = { + ...process.env, + HOME: home, + USERPROFILE: home, + PATH: `${ambientBin}:/usr/bin:/bin`, + // Stock cases replay this replacement after the fixture installs its own widgets. + DEBIAN_PREVENT_KEYBOARD_CHANGES: '1', + ZDOTDIR: wrapperDir, + ORCA_HISTFILE: join(home, 'scoped-history') + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath: home }) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + hasStartupCommand: !historyOnly, + waitsForShellReady: !historyOnly, + emitsStartupIdentity: false + }) + env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(features) + if (!historyOnly) { + env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'O_SU=$((${O_SU:-0}+1))' + } + + if (statusPrecmd || stickyPrecmd) { + const baseline = await runZshPty({ env: { ...env, ZDOTDIR: home }, report: ['O_FIRST_IN'] }) + expect(baseline.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') + } + + const result = await runZshPty({ + env, + commands: [ + ...(repeatSource + ? ['source -- "$HOME/wrapper/.zshenv"', 'source -- "$HOME/wrapper/.zshenv"'] + : []), + 'O_LK=$(command -v orca-dev)', + 'O_IR=${+functions[__orca_deferred_line_init]}', + 'O_SR=${+widgets[__orca_saved_line_init]}', + ...(scheduleCleanup + ? [ + 'O_SC=${+functions[__orca_deferred_sched_init]}', + 'O_SE=${zsh_scheduled_events[*]:-UNSET}' + ] + : []), + ...(scheduledPrecmd ? ['O_EV=${#zsh_scheduled_events}'] : []), + ...(stockBinding ? ['O_RW=${widgets[zle-line-pre-redraw]:-none}'] : []), + 'O_LI=${widgets[zle-line-init]:-none}', + 'O_PC="${precmd_functions[*]}"', + ...(nonzeroPrecmd ? ['precmd; O_PS=$?'] : []) + ], + report: [ + 'O_LK', + 'O_UC', + 'O_UN', + 'O_IR', + 'O_SR', + ...(scheduleCleanup ? ['O_SC', 'O_SE'] : []), + ...(scheduledPrecmd ? ['O_EV', 'O_EO'] : []), + ...(stockBinding ? ['O_RW', 'O_RC', 'O_RN'] : []), + ...(nonzeroPrecmd ? ['O_PCALLS', 'O_PN', 'O_PO', 'O_AC', 'O_PS'] : []), + ...(orderedPrecmd ? ['O_PCALLS', 'O_PN', 'O_AO'] : []), + ...(replacedPrecmd ? ['O_PCALLS', 'O_NPC', 'O_NPN'] : []), + ...(statusPrecmd || stickyPrecmd ? ['O_FIRST_IN'] : []), + 'O_LI', + 'O_PC', + 'O_SU', + 'HISTFILE' + ] + }) + + expect(result.values.O_LK).toBe(launcher) + expect(Number(result.values.O_UC)).toBeGreaterThan(0) + if (!chainedLineInit) { + expect(result.values.O_UN).toBe('zle-line-init') + } + if (!chainedLineInit && !chainedRedraw && !repeatSource) { + expect(result.values.O_IR).toBe('0') + } + expect(result.values.O_SR).toBe('0') + if (repeatSource) { + expect(result.output).not.toContain('job table full or recursion limit exceeded') + expect(result.values.O_PC).not.toContain('__orca_deferred_init') + } + if (scheduleCleanup) { + expect(result.values.O_SC).toBe('0') + expect(result.values.O_SE).not.toContain('orca') + } + if (unavailableSched) { + // Stock completion modules can fail before the fixture restores module_path. + expect(result.output).not.toContain('zsh/sched') + expect(result.output).not.toContain('__orca_arm_deferred_line_init:') + expect(result.values.O_SE).toBe('UNSET') + } + if (scheduledPrecmd) { + expect(result.values.O_EV).toBe('1') + expect(result.values.O_EO).toBe('0') + expect(result.values.O_SE).toContain('O_EVENT') + } + if (stockBinding) { + expect(Number(result.values.O_RC)).toBeGreaterThan(0) + expect(result.values.O_RN).toBe(chainedRedraw ? 'orca_test_redraw' : 'zle-line-pre-redraw') + if (!chainedRedraw) { + expect(result.values.O_RW).toBe('user:orca_test_redraw') + } + } + expect(result.values.HISTFILE).toBe(join(home, 'scoped-history')) + if (nonzeroPrecmd) { + expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) + expect(result.values.O_PN).toBe('precmd') + expect(result.values.O_PO).toBe('on:on') + expect(result.values.O_AC).toBe('called') + expect(result.values.O_PS).toBe('1') + } + if (orderedPrecmd) { + expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) + expect(result.values.O_PN).toBe('precmd') + expect(result.values.O_AO).toBe('0') + } + if (replacedPrecmd) { + expect(result.values.O_PCALLS).toBe('UNSET') + expect(Number(result.values.O_NPC)).toBeGreaterThan(0) + expect(result.values.O_NPN).toBe('precmd') + } + if (statusPrecmd || stickyPrecmd) { + expect(result.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') + expect(result.output).toContain('ORCA_FIRST_PROMPT:42 ') + } + if (historyOnly) { + expect(result.output).not.toContain('\x1b]133;') + expect(result.values.O_LI).toBe( + stockBinding ? 'user:zle-line-init' : 'user:orca_test_line_init' + ) + expect(result.values.O_PC).not.toContain('orca') + expect(result.values.O_SU).toBe('UNSET') + } else { + expect(result.output).toContain(MARKERS.ready) + expect(result.values.O_LI).toBe('user:__orca_prompt_mark') + expect(result.values.O_PC).toBe( + nonzeroPrecmd || orderedPrecmd + ? 'orca_test_array __orca_osc133_precmd' + : '__orca_osc133_precmd' + ) + expect(result.values.O_SU).toBe('1') + expect(result.output.split('ORCA_TEST_USER_WIDGET_CALL\r\n').length - 1).toBe( + result.output.split(MARKERS.ready).length - 1 + ) + } + } + ) +}) diff --git a/src/main/zsh-deferred-startup-line-init.ts b/src/main/zsh-deferred-startup-line-init.ts new file mode 100644 index 00000000000..95b9da94df6 --- /dev/null +++ b/src/main/zsh-deferred-startup-line-init.ts @@ -0,0 +1,56 @@ +// Why: stock zshrc can replace line-init after the user clears the prompt-hook array. +export const ZSH_DEFERRED_LINE_INIT_BLOCK = `__orca_deferred_line_init() { + builtin emulate -L zsh + (( \${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && \${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( \${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "\${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( \${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +}` + +// Why: restore the exact prior widget before the existing readiness hook captures it. +export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if (( \${+widgets[__orca_saved_line_init]} )); then + if [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi` + +// Why: add-zle-hook-widget can keep an alias of the bootstrap in its own chain. +export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "\${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init` diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 68ee27d24e8..5c5020c96bc 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -333,25 +333,14 @@ describe.skipIf(process.platform === 'win32')( }) itWithZsh( - 'degrades to an unwrapped pane, leaking nothing, when a config drops precmd_functions', + 'still scopes history without leaking it when a config drops precmd_functions', withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => { const scoped = join(home, 'orca-history', 'zsh_history') const { env, launch } = launchPane(home, scoped) const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR'] const { values } = await runZshPty({ env, report }) - // Why compared against an unwrapped run rather than asserted to differ - // from the scoped path: whether the scoped value survives at all is the - // host's call, not Orca's. macOS /etc/zshrc overwrites HISTFILE, so it - // does not; a host with no such assignment keeps whatever the spawn env - // set. The contract on both is the same — this pane is the pane the user - // would have had unwrapped. - const unwrapped = await runZshPty({ - env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: scoped }, - report - }) - - expect(values.HISTFILE).toBe(unwrapped.values.HISTFILE) + expect(values.HISTFILE).toBe(scoped) expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) // ORCA_HISTFILE was consumed in .zshenv precisely so a dropped hook // leaks nothing to the pane's children. diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index d6d6ef39a30..6ec28b647c2 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -1,3 +1,4 @@ +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-account-shell' /** * The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH, @@ -14,7 +15,8 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou * dir shared by two installed builds could mix files from both. * * This shape gives ZDOTDIR back before anything else can observe it, then defers - * Orca's work to a `precmd` hook that runs at the first prompt — after + * Orca's work to a `precmd` hook, with a one-shot line-editor fallback if the + * user's config replaces its hook array. Both run at the first prompt — after * `.zprofile`, `/etc/zshrc`, `.zshrc` and `.zlogin`, all of which zsh now reads * from the user's own directory exactly as in an unwrapped shell. #11044 becomes * unreachable rather than repaired, and the emulation and mixed-build classes @@ -30,6 +32,11 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { + ZSH_DEFERRED_LINE_INIT_BLOCK, + ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK, + ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK +} from './zsh-deferred-startup-line-init' import { getZshShellReadyMarkerRegistrationBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, @@ -146,6 +153,7 @@ function buildDeferredInit(spec: ZshStartupHookSpec): string { const permanentPrecmd = spec.osc133CommandMarkers ? ` if __orca_has_feature markers; then precmd_functions=(\${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( \${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]}) else precmd_functions=(\${precmd_functions:#__orca_deferred_init}) @@ -170,9 +178,11 @@ ${indentBlock(getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped, t (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions +${ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK} ${permanentPrecmd} ${joinBlocks([ spec.restores.managedWslCli ? indentBlock(WSL_MANAGED_CLI_PATH_RESTORE, ' ') : null, + indentBlock(ORCA_CLI_POSIX_PATH_RESTORE, ' ').replace(/\n$/, ''), featureGuard('overlay', getOverlayRestoreBlocks(spec)), // Why outside the overlay guard: a system-default Codex home carries no overlay key. indentBlock(getPosixCodexShellLaunchPreflight(), ' ').replace(/\n$/, ''), @@ -190,7 +200,8 @@ ${ __orca_has_feature markers && __orca_osc133_precmd\n` : '' } builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature +${ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK} + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init }` } @@ -201,6 +212,7 @@ export function buildZshStartupHook(spec: ZshStartupHookSpec): string { ZSH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, spec.osc133CommandMarkers ? ZSH_OSC133_FUNCTION_BLOCK : null, + ZSH_DEFERRED_LINE_INIT_BLOCK, buildDeferredInit(spec), ZSH_USER_ZSHENV_SOURCE_BLOCK ])}\n` diff --git a/src/preload/api/github-work-item-api.ts b/src/preload/api/github-work-item-api.ts index ae95a908fb6..db5842eb2cf 100644 --- a/src/preload/api/github-work-item-api.ts +++ b/src/preload/api/github-work-item-api.ts @@ -75,6 +75,7 @@ export type GithubWorkItemApi = { args: GitHubRepoSelectorArgs & { number: number type?: 'issue' | 'pr' + ownerRepo?: GitHubOwnerRepo } ) => Promise notifyWorkItemMutated: (args: { @@ -106,6 +107,7 @@ export type GithubWorkItemApi = { args: GitHubRepoSelectorArgs & { number: number updates: GitHubIssueUpdate + ownerRepo?: GitHubOwnerRepo } ) => Promise<{ ok: true } | { ok: false; error: string }> addIssueComment: ( @@ -121,11 +123,13 @@ export type GithubWorkItemApi = { repoPath: string repoId?: string sourceContext?: TaskSourceContext | null + ownerRepo?: GitHubOwnerRepo }) => Promise listAssignableUsers: (args: { repoPath: string repoId?: string sourceContext?: TaskSourceContext | null + ownerRepo?: GitHubOwnerRepo }) => Promise /** Subscribe to local-mutation broadcasts so the work-item-drawer cache can invalidate across windows. Returns an unsubscribe. */ onWorkItemMutated: ( diff --git a/src/relay/ai-vault-service-cancellation.test.ts b/src/relay/ai-vault-service-cancellation.test.ts new file mode 100644 index 00000000000..8354eaabaae --- /dev/null +++ b/src/relay/ai-vault-service-cancellation.test.ts @@ -0,0 +1,348 @@ +import { setImmediate } from 'node:timers/promises' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { RelayAiVaultServiceClient } from './ai-vault-service-client' +import { AiVaultServiceTestChild } from '../main/ai-vault/session-scanner-service-test-child' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import type { RelayAiVaultServiceChildMessage } from './ai-vault-service-protocol' +import type { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' + +const scan = vi.fn() +const disposeProvider = vi.fn<() => void>() + +const result = { sessions: [], issues: [], scannedAt: '2026-10-02T13:00:00.000Z' } +let outbound: RelayAiVaultServiceChildMessage[] = [] +let frames: RelayAiVaultServiceChildMessage[] = [] +let restoreProcess = (): void => undefined +let client: RelayAiVaultServiceClient | undefined + +class BridgeChild extends AiVaultServiceTestChild { + override send(message: unknown, callback?: (error: Error | null) => void): boolean { + super.send(message, callback) + process.emit('message', message) + return true + } +} + +function deliver(child: BridgeChild): void { + const message = outbound.shift() + if (!message) { + throw new Error('Expected one complete service reply in transit') + } + child.emit('message', message) +} + +async function turn(): Promise { + await setImmediate() +} + +function createClient(): BridgeChild { + const child = new BridgeChild() + client = new RelayAiVaultServiceClient({ + processFactory: () => child.asChildProcess(), + init: { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64') } + }) + return child +} + +function currentClient(): RelayAiVaultServiceClient { + if (!client) { + throw new Error('Expected the owned service client') + } + return client +} + +async function close(child: BridgeChild): Promise { + const disposed = client?.dispose() + await turn() + child.emit('exit', 0) + await disposed + expect(disposeProvider).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + client = undefined +} + +beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + scan.mockReset() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + outbound = [] + frames = [] + scan.mockResolvedValue(result) + const send = Object.getOwnPropertyDescriptor(process, 'send') + const disconnect = Object.getOwnPropertyDescriptor(process, 'disconnect') + const messages = new Set(process.listeners('message')) + const disconnects = new Set(process.listeners('disconnect')) + Object.defineProperty(process, 'send', { + configurable: true, + value: (message: RelayAiVaultServiceChildMessage) => { + outbound.push(message) + frames.push(message) + return true + } + }) + Object.defineProperty(process, 'disconnect', { configurable: true, value: vi.fn() }) + restoreProcess = () => { + for (const listener of process.listeners('message')) { + if (!messages.has(listener)) { + process.removeListener('message', listener) + } + } + for (const listener of process.listeners('disconnect')) { + if (!disconnects.has(listener)) { + process.removeListener('disconnect', listener) + } + } + if (send) { + Object.defineProperty(process, 'send', send) + } else { + delete process.send + } + if (disconnect) { + Object.defineProperty(process, 'disconnect', disconnect) + } else { + delete process.disconnect + } + } + const scanner = await import('../main/ai-vault/remote-session-scanner') + vi.spyOn(scanner, 'scanRemoteAiVaultSessions').mockImplementation(scan) + const titles = await import('../main/ai-vault/session-title-file-reader') + vi.spyOn(titles, 'readAiVaultSessionTitlesFromFiles').mockResolvedValue({ titles: [] }) + const filesystem = await import('./ai-vault-service-filesystem') + const createProvider = filesystem.createRelayAiVaultFilesystemProvider + vi.spyOn(filesystem, 'createRelayAiVaultFilesystemProvider').mockImplementation((options) => { + const provider = createProvider(options) + const dispose = provider.dispose + vi.spyOn(provider, 'dispose').mockImplementation(() => { + disposeProvider() + dispose() + }) + return provider + }) + await import('./ai-vault-service-entry') +}) + +afterEach(async () => { + process.emit('message', { type: 'shutdown' }) + await turn() + restoreProcess() + vi.clearAllTimers() + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it('actual parent cancellation during completed-response transit retains zero completed IDs', async () => { + const child = createClient() + const errors: unknown[] = [] + const retained: Set[] = [] + const counts: number[] = [] + const warm = Promise.withResolvers() + scan.mockImplementationOnce(() => warm.promise) + const warmController = new AbortController() + const warmResponse = currentClient() + .listSessions({}, warmController.signal) + .catch((error: unknown) => error) + deliver(child) + await turn() + const warmAdd = vi.spyOn(Set.prototype, 'add') + try { + warmController.abort() + const callIndex = warmAdd.mock.calls.findIndex(([value]) => value === 1) + const owned = warmAdd.mock.contexts[callIndex] + if (!(owned instanceof Set)) { + throw new Error('Expected the actual native cancellation set for the admitted warm request') + } + retained.push(owned) + } finally { + warmAdd.mockRestore() + } + errors.push(await warmResponse) + warm.resolve(result) + await turn() + deliver(child) + await turn() + expect(retained[0]?.size).toBe(0) + for (let index = 0; index < 64; index++) { + const id = index + 2 + if (index % 3 !== 2) { + scan.mockImplementationOnce(async () => { + if (index % 3 === 1) { + throw new Error(`scan failure ${id}`) + } + return result + }) + } + const controller = new AbortController() + const response = ( + index % 3 === 2 + ? currentClient().resolveSessionTitles([], controller.signal) + : currentClient().listSessions({}, controller.signal) + ).catch((error: unknown) => error) + await turn() + expect(outbound).toHaveLength(1) + expect(outbound[0]).toEqual( + index % 3 === 2 + ? { type: 'result', id, operation: 'titles', value: { titles: [] } } + : index % 3 === 1 + ? { type: 'error', id, message: `scan failure ${id}` } + : { type: 'result', id, operation: 'list', value: result } + ) + const add = vi.spyOn(Set.prototype, 'add') + try { + controller.abort() + const matching = add.mock.calls.flatMap(([value], callIndex) => + value === id ? [callIndex] : [] + ) + counts.push(matching.length) + for (const callIndex of matching) { + const owned = add.mock.contexts[callIndex] + if (!(owned instanceof Set)) { + throw new Error('Expected the actual native cancellation set') + } + retained.push(owned) + } + } finally { + add.mockRestore() + } + errors.push(await response) + deliver(child) + await turn() + } + expect( + errors.every( + (error) => + error instanceof Error && + error.name === 'AbortError' && + error.message === 'The operation was aborted.' + ) + ).toBe(true) + expect(scan).toHaveBeenCalledTimes(44) + expect( + child.sent.filter( + (message) => + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === 'cancel' + ) + ).toEqual(Array.from({ length: 65 }, (_, index) => ({ type: 'cancel', id: index + 1 }))) + const sizes = [...new Set(retained)].map((owned) => owned.size) + await close(child) + expect(frames).toEqual([ + { type: 'ready', protocol: 1, pid: process.pid }, + { type: 'result', id: 1, operation: 'list', value: result }, + ...Array.from({ length: 64 }, (_, index) => { + const id = index + 2 + return index % 3 === 2 + ? { type: 'result', id, operation: 'titles', value: { titles: [] } } + : index % 3 === 1 + ? { type: 'error', id, message: `scan failure ${id}` } + : { type: 'result', id, operation: 'list', value: result } + }) + ]) + expect(counts).toEqual(Array(64).fill(0)) + expect(sizes).toEqual([0]) +}) + +it('an admitted active cancellation still aborts its exact scanner signal', async () => { + const child = createClient() + const pending = Promise.withResolvers() + let signal: AbortSignal | undefined + scan.mockImplementationOnce((options) => { + signal = options.signal + return pending.promise + }) + const controller = new AbortController() + const response = currentClient() + .listSessions({}, controller.signal) + .catch((error: unknown) => error) + deliver(child) + await turn() + expect(signal?.aborted).toBe(false) + controller.abort() + await expect(response).resolves.toMatchObject({ name: 'AbortError' }) + expect(signal?.aborted).toBe(true) + pending.resolve(result) + await turn() + expect(outbound).toEqual([{ type: 'result', id: 1, operation: 'list', value: result }]) + deliver(child) + await close(child) +}) + +it('a pending queued cancellation still aborts before the queued scanner begins', async () => { + const child = createClient() + const pending = Promise.withResolvers() + const signals: AbortSignal[] = [] + scan.mockImplementation((options) => { + if (!options.signal) { + throw new Error('Expected the owned scanner signal') + } + signals.push(options.signal) + return signals.length === 1 ? pending.promise : Promise.resolve(result) + }) + const response = currentClient().listSessions({}) + deliver(child) + await turn() + process.emit('message', { type: 'request', id: 999, operation: 'list', params: {} }) + process.emit('message', { type: 'cancel', id: 999 }) + expect(signals).toHaveLength(1) + pending.resolve(result) + await turn() + expect(signals).toHaveLength(2) + expect(signals[1]?.aborted).toBe(true) + expect(outbound).toEqual([ + { type: 'result', id: 1, operation: 'list', value: result }, + { type: 'result', id: 999, operation: 'list', value: result } + ]) + deliver(child) + await expect(response).resolves.toEqual(result) + deliver(child) + await close(child) +}) + +it('unknown, pre-init and duplicate completed cancels preserve the next live request', async () => { + process.emit('message', { type: 'cancel', id: 9000 }) + const child = createClient() + const first = currentClient().listSessions({}) + deliver(child) + await turn() + deliver(child) + await expect(first).resolves.toEqual(result) + for (const id of [1, 1, 9000, 9000]) { + process.emit('message', { type: 'cancel', id }) + } + expect(outbound).toEqual([]) + const live = currentClient().listSessions({}) + await turn() + expect(scan.mock.calls[1]?.[0].signal?.aborted).toBe(false) + expect(outbound).toEqual([{ type: 'result', id: 2, operation: 'list', value: result }]) + deliver(child) + await expect(live).resolves.toEqual(result) + await close(child) +}) + +it('shutdown aborts active work and ignores later cancellations and admission', async () => { + const child = createClient() + const pending = Promise.withResolvers() + let signal: AbortSignal | undefined + scan.mockImplementationOnce((options) => { + signal = options.signal + return pending.promise + }) + const response = currentClient().listSessions({}) + const rejected = expect(response).rejects.toThrow('scan stopped') + deliver(child) + await turn() + process.emit('message', { type: 'shutdown' }) + expect(signal?.aborted).toBe(true) + process.emit('message', { type: 'cancel', id: 1 }) + process.emit('message', { type: 'cancel', id: 9000 }) + process.emit('message', { type: 'request', id: 9000, operation: 'list', params: {} }) + pending.reject(new Error('scan stopped')) + await turn() + expect(scan).toHaveBeenCalledOnce() + expect(outbound).toEqual([{ type: 'error', id: 1, message: 'scan stopped' }]) + deliver(child) + await rejected + await close(child) +}) diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts index 9380e08d4c1..63c67885ca1 100644 --- a/src/relay/ai-vault-service-entry.ts +++ b/src/relay/ai-vault-service-entry.ts @@ -99,6 +99,9 @@ process.on('message', (raw: RelayAiVaultServiceParentMessage) => { return } if (raw?.type === 'cancel') { + if (!pending.has(raw.id)) { + return + } cancelled.add(raw.id) controllers.get(raw.id)?.abort() return diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index baf6101600c..b6829921cb4 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -44,26 +44,22 @@ describe('relay list-files cancellation', () => { vi.useRealTimers() }) - it('listFilesWithRg kills both rg passes and rejects when aborted mid-flight', async () => { - const primaryProc = createMockProcess() + it('listFilesWithRg kills the broad rg pass and rejects when aborted mid-flight', async () => { const ignoredProc = createMockProcess() - spawnMock.mockImplementation((_cmd: string, args: string[]) => - args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc - ) + spawnMock.mockReturnValue(ignoredProc) const controller = new AbortController() const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) // Partial output before the abort — must be discarded, not resolved. - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') controller.abort() await expect(promise).rejects.toSatisfy(isFileListingCancellation) - expect(primaryProc.kill).toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) expect(ignoredProc.kill).toHaveBeenCalled() // Late close events after cancellation must not fire anything. - primaryProc.emit('close', null, 'SIGTERM') ignoredProc.emit('close', null, 'SIGTERM') }) @@ -78,19 +74,15 @@ describe('relay list-files cancellation', () => { }) it('listFilesWithRg still resolves normally when a signal is provided but never aborted', async () => { - const primaryProc = createMockProcess() const ignoredProc = createMockProcess() - spawnMock.mockImplementation((_cmd: string, args: string[]) => - args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc - ) + spawnMock.mockReturnValue(ignoredProc) const controller = new AbortController() const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') - primaryProc.emit('close', 0, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') + ignoredProc.stdout?.emit('data', 'dist/out.js\0') ignoredProc.emit('close', 0, null) }, 5) diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 483afec1c4c..36a3914d021 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -71,35 +71,40 @@ describe('relay quick open ignored file listing', () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) - it('rg ignored pass includes ignored non-env files and keeps blocklists/excludes', async () => { - const primaryProc = createMockProcess() + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const promise = listFilesWithRg('/remote/root') + + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + + it('uses one broad rg pass for unbounded listings and keeps blocklists/excludes', async () => { const ignoredProc = createMockProcess() - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args.includes('--no-ignore-vcs')) { - return ignoredProc - } - return primaryProc - }) + spawnMock.mockReturnValue(ignoredProc) const promise = listFilesWithRg('/remote/root', ['packages/other']) - expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') - primaryProc.emit('close', 0, null) - - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'node_modules/pkg/index.js\0') + ignoredProc.stdout?.emit('data', 'packages/other/src/x.ts\0') ignoredProc.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) - const ignoredArgs = spawnMock.mock.calls.find((call) => - (call[1] as string[]).includes('--no-ignore-vcs') - )?.[1] as string[] + const ignoredArgs = spawnMock.mock.calls[0][1] expect(ignoredArgs).toBeDefined() expect(ignoredArgs).toContain('--no-ignore-vcs') expect(ignoredArgs).not.toContain('.env*') @@ -116,10 +121,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - 'src/one.ts\nsrc/two.ts\nsrc/three.ts\n' - ) + primaryProc.stdout?.emit('data', 'src/one.ts\0src/two.ts\0src/three.ts\0') await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -137,19 +139,34 @@ describe('relay quick open ignored file listing', () => { expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') - ;(ignoredProc.stdout as unknown as EventEmitter).emit( + ignoredProc.stdout?.emit( 'data', - `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n` - ) - ;(ignoredProc.stdout as unknown as EventEmitter).emit( - 'data', - 'src/components/target.ts\nscripts/check-target.ts\n' + `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0` ) + ignoredProc.stdout?.emit('data', 'src/components/target.ts\0scripts/check-target.ts\0') ignoredProc.emit('close', 0, null) await expect(promise).resolves.toEqual(['scripts/check-target.ts', 'src/components/target.ts']) }) + it('fills a bounded listing from primary files before admitting ignored files', async () => { + const primary = createMockProcess() + const broad = createMockProcess() + spawnMock.mockReturnValueOnce(primary).mockReturnValueOnce(broad) + + const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0][1]).not.toContain('--no-ignore-vcs') + primary.stdout?.emit('data', 'src/index.ts\0') + primary.emit('close', 0, null) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs') + broad.stdout?.emit('data', 'src/index.ts\0dist/generated.js\0dist/extra.js\0') + + await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) + expect(broad.kill).toHaveBeenCalled() + }) + it('retries a transient remote rg spawn failure without reporting ripgrep as missing', async () => { const failed = createMockProcess() const succeeded = createMockProcess() @@ -160,14 +177,11 @@ describe('relay quick open ignored file listing', () => { maxResults: 32, searchQuery: 'target' }) - ;(failed.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + failed.stdout?.emit('data', 'src/target.ts\0') failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(succeeded.stdout as unknown as EventEmitter).emit( - 'data', - 'src/target.ts\nsrc/another-target.ts\n' - ) + succeeded.stdout?.emit('data', 'src/target.ts\0src/another-target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/target.ts', 'src/another-target.ts']) @@ -185,38 +199,33 @@ describe('relay quick open ignored file listing', () => { searchQuery: 'target' }) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + succeeded.stdout?.emit('data', 'src/target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/target.ts']) }) - it('runs the ignored pass after an unbounded primary listing retry succeeds', async () => { - const failedPrimary = createMockProcess() - const succeededPrimary = createMockProcess() - const ignored = createMockProcess() - Object.defineProperty(failedPrimary, 'pid', { value: undefined }) - spawnMock - .mockReturnValueOnce(failedPrimary) - .mockReturnValueOnce(succeededPrimary) - .mockReturnValueOnce(ignored) + it('retries an unbounded broad listing once after a transient spawn failure', async () => { + const failed = createMockProcess() + const succeeded = createMockProcess() + Object.defineProperty(failed, 'pid', { value: undefined }) + spawnMock.mockReturnValueOnce(failed).mockReturnValueOnce(succeeded) const promise = listFilesWithRg('/remote/root') - failedPrimary.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) + failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(succeededPrimary.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') - succeededPrimary.emit('close', 0, null) - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(3)) - ;(ignored.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ignored.emit('close', 0, null) + succeeded.stdout?.emit('data', 'src/index.ts\0dist/generated.js\0') + succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) - expect(spawnMock.mock.calls[2][1]).toContain('--no-ignore-vcs') + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') + expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs') }) it.each(['error-first', 'close-first'] as const)( - 'tags a %s pre-spawn listing failure without starting the ignored pass', + 'tags a %s pre-spawn listing failure without starting another pass', async (order) => { const root = await makeTempRoot() const missing = createMockProcess() @@ -243,21 +252,17 @@ describe('relay quick open ignored file listing', () => { } ) - it('kills only the admitted pass when ignored rg fails before spawn', async () => { + it('does not signal the unbounded broad pass when it fails before spawn', async () => { const root = await makeTempRoot() - const primary = createMockProcess() const missingIgnored = createMockProcess() Object.defineProperty(missingIgnored, 'pid', { value: undefined }) - spawnMock.mockImplementation((_cmd: string, args: string[]) => - args.includes('--no-ignore-vcs') ? missingIgnored : primary - ) + spawnMock.mockReturnValue(missingIgnored) const promise = listFilesWithRg(root) - expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) missingIgnored.emit('close', -2, null) await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) - expect(primary.kill).toHaveBeenCalled() expect(missingIgnored.kill).not.toHaveBeenCalled() const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) expect(() => missingIgnored.emit('error', error)).not.toThrow() @@ -306,18 +311,12 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit(root, ['packages/other']) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'src/index.ts')}\0` - ) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'tab\tfile.txt')}\0` - ) + primaryProc.stdout?.emit('data', `${staged('100644', 'src/index.ts')}\0`) + primaryProc.stdout?.emit('data', `${staged('100644', 'tab\tfile.txt')}\0`) primaryProc.emit('close', 0, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/\0') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\0') + ignoredProc.stdout?.emit('data', 'dist/\0') + ignoredProc.stdout?.emit('data', 'packages/other/src/x.ts\0') ignoredProc.emit('close', 0, null) }, 10) @@ -347,7 +346,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) const promise = listFilesWithGit('/remote/root', [], { maxResults: 2 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/one.ts\0src/two.ts') + primaryProc.stdout?.emit('data', 'src/one.ts\0src/two.ts') primaryProc.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -360,10 +359,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockReturnValue(primaryProc) const promise = listFilesWithGit('/remote/root', [], { maxResults: 1 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `discarded/\0${staged('100644', 'src/kept.ts')}\0` - ) + primaryProc.stdout?.emit('data', `discarded/\0${staged('100644', 'src/kept.ts')}\0`) await expect(promise).resolves.toEqual(['src/kept.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -390,7 +386,7 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit(root) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit( + primaryProc.stdout?.emit( 'data', `${staged('100644', 'README.md')}\0${staged('160000', 'packages/app')}\0packages/lib/\0` ) @@ -420,11 +416,11 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) // Entries streamed before the kill are kept alongside the primary pass. - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', null, 'SIGTERM') }, 10) @@ -450,7 +446,7 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) ignoredProc.emit('close', 128, null) @@ -476,10 +472,10 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', null, 'SIGTERM') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', 0, null) }, 10) @@ -501,7 +497,7 @@ describe('relay quick open ignored file listing', () => { setTimeout(() => { primaryProc.emit('close', 128, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', 0, null) }, 10) @@ -548,14 +544,8 @@ describe('relay quick open ignored file listing', () => { it('rg file listing rejects and detaches when a timed-out child does not emit close', async () => { vi.useFakeTimers() try { - const primaryProc = createMockProcess() const ignoredProc = createMockProcess() - let callIndex = 0 - - spawnMock.mockImplementation(() => { - callIndex++ - return callIndex === 1 ? primaryProc : ignoredProc - }) + spawnMock.mockReturnValue(ignoredProc) const promise = listFilesWithRg('/remote/root') const outcomePromise = promise.then( @@ -567,12 +557,8 @@ describe('relay quick open ignored file listing', () => { const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) expect(outcome).toBe('rejected:rg list timed out') - expect(primaryProc.kill).toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) expect(ignoredProc.kill).toHaveBeenCalled() - expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(primaryProc.listenerCount('error')).toBe(0) - expect(primaryProc.listenerCount('close')).toBe(0) expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(ignoredProc.listenerCount('error')).toBe(0) @@ -744,12 +730,12 @@ describe('relay quick open ignored file listing', () => { expect(() => probe.emit('error', error)).not.toThrow() }) - it('keeps post-spawn rg search errors on the existing empty-result path', async () => { + it('rejects post-spawn rg search errors instead of returning an empty result', async () => { const started = createMockProcess() Object.defineProperty(started, 'pid', { value: 1 }) spawnMock.mockReturnValueOnce(started) const ordinaryFailure = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) started.emit('error', new Error('post-spawn failure')) - await expect(ordinaryFailure).resolves.toMatchObject({ files: [], totalMatches: 0 }) + await expect(ordinaryFailure).rejects.toThrow('post-spawn failure') }) }) diff --git a/src/relay/fs-handler-list-files-path-framing.test.ts b/src/relay/fs-handler-list-files-path-framing.test.ts new file mode 100644 index 00000000000..037bbc97a41 --- /dev/null +++ b/src/relay/fs-handler-list-files-path-framing.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { rgPath } from '@vscode/ripgrep-universal' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' +import { listFilesWithRg } from './fs-handler-list-files' + +let root: string | undefined + +afterEach(async () => { + configureRelayBundledRipgrep(undefined) + vi.unstubAllEnvs() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +it('ignores user rg configuration when listing files', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-config-')) + configureRelayBundledRipgrep(rgPath) + const config = join(root, 'config') + await writeFile(config, '--glob\n!*.ts\n') + await writeFile(join(root, 'visible.ts'), '') + vi.stubEnv('RIPGREP_CONFIG_PATH', config) + + expect(await listFilesWithRg(root)).toContain('visible.ts') + expect(await listFilesWithRg(root, [], { maxResults: 10 })).toContain('visible.ts') +}) + +it.skipIf(process.platform === 'win32')( + 'preserves newline, carriage return and Unicode filenames', + async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-filenames-')) + configureRelayBundledRipgrep(rgPath) + const names = ['first\nsecond.ts', 'trailing\r', 'résumé-😀.ts', 'spaces .ts '] + const fixtureRoot = root + await Promise.all(names.map((name) => writeFile(join(fixtureRoot, name), ''))) + + expect((await listFilesWithRg(root)).sort()).toEqual([...names].sort()) + expect((await listFilesWithRg(root, [], { maxResults: 10 })).sort()).toEqual([...names].sort()) + expect(await listFilesWithRg(root, [], { searchQuery: 'second', maxResults: 1 })).toEqual([ + 'first\nsecond.ts' + ]) + } +) + +it.skipIf(process.platform !== 'linux')( + 'rejects real non-UTF8 filenames instead of fabricating paths', + async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-invalid-name-')) + configureRelayBundledRipgrep(rgPath) + const invalidPath = Buffer.concat([Buffer.from(join(root, 'bad-')), Buffer.from([0xff])]) + await writeFile(invalidPath, '') + await expect(listFilesWithRg(root)).rejects.toThrow('not valid UTF-8') + } +) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index d2b4732777c..ca25cd85e9e 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -1,3 +1,4 @@ +import { RipgrepFilenameDecoder } from '../shared/ripgrep-filename-decoder' /** * Ripgrep-based file listing for Quick Open. * Why a full rewrite vs. the older execFile+maxBuffer version: on a home-dir @@ -6,7 +7,7 @@ * matching files" even though the file existed on disk. This implementation: * - streams via spawn (no maxBuffer failure mode) * - prunes traversal at rg level using the shared blocklist globs - * - runs a second --no-ignore-vcs pass for ignored files + * - includes gitignored files, preserving primary-first order for bounded listings * - honors excludePathPrefixes for nested linked worktrees * - rejects (not resolves) on timeout / spawn error / signal exit so * the UI shows a load error instead of a false-empty list @@ -97,6 +98,10 @@ export function listFilesWithRg( new Promise((passResolve, passReject) => { const attemptRanker = searchQuery === undefined ? null : new QuickOpenPathRanker(searchQuery, maxResults ?? 16) + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + rejectPass(error) + }) let passBuf = '' let passDone = false let passFileCount = 0 @@ -126,11 +131,9 @@ export function listFilesWithRg( ) : error } - let timer: ReturnType | null = null const cleanup = (): void => { if (timer) { clearTimeout(timer) - timer = null } child.stdout?.off('data', handleStdoutData) child.stderr?.off('data', handleStderrData) @@ -188,17 +191,21 @@ export function listFilesWithRg( } children.push({ child, isDone: () => passDone, reject: rejectPass }) - timer = setTimeout(() => { + const timer = setTimeout(() => { // Discard residual buffer on abnormal exit — a truncated byte // sequence could look like a valid path. killSpawnedRipgrepProcess(child) rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) - function handleStdoutData(chunk: string): void { - passBuf += chunk + function handleStdoutData(chunk: Buffer | string): void { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + passBuf += decoded let start = 0 - let idx = passBuf.indexOf('\n', start) + let idx = passBuf.indexOf('\0', start) while (idx !== -1) { if (processLine(passBuf.substring(start, idx), attemptRanker)) { passFileCount++ @@ -207,7 +214,7 @@ export function listFilesWithRg( return } start = idx + 1 - idx = passBuf.indexOf('\n', start) + idx = passBuf.indexOf('\0', start) } passBuf = start < passBuf.length ? passBuf.substring(start) : '' } @@ -248,6 +255,9 @@ export function listFilesWithRg( rejectPass(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } // Flush residual line only on clean exit. if (passBuf) { if (processLine(passBuf, attemptRanker)) { @@ -259,16 +269,13 @@ export function listFilesWithRg( // (e.g. EACCES on .ssh), but rg also returns 2 for fatal errors // (bad flag, invalid glob). Only trust exit 2 when rg emitted at // least one parseable path — otherwise treat it as a real failure. - if (code === 0 || code === 1) { - resolvePass() - } else if (code === 2 && passFileCount > 0) { + if (code === 0 || code === 1 || (code === 2 && passFileCount > 0)) { resolvePass() } else { rejectPass(new Error(`rg exited with code ${code}`)) } } - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) @@ -284,10 +291,7 @@ export function listFilesWithRg( }) const killSurvivors = (reason: string): void => { - // Why: when one pass rejects, Promise.all surfaces the error immediately - // but the sibling rg keeps running up to LIST_FILES_TIMEOUT_MS. Kill it - // so repeated Quick Open opens don't pile up orphan rg processes on the - // remote. + // Cancellation or a reached budget must stop any admitted scan or retry. for (const entry of children) { if (entry.isDone()) { continue @@ -322,21 +326,13 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) + // Without a result budget, the broader pass already contains every primary path. const passes = - searchQuery !== undefined + searchQuery !== undefined || maxResults === undefined ? runPass(ignoredPass) - : (() => { - const primaryPass = runPass(primary) - return maxResults === undefined - ? children[0]?.child.pid === undefined - ? primaryPass.then(() => runPass(ignoredPass)) - : Promise.all([primaryPass, runPass(ignoredPass)]) - : // Why: deterministic primary-first budgeting prevents a large ignored - // tree from starving ordinary source paths on a remote host. - primaryPass.then(() => - files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() - ) - })() + : runPass(primary).then(() => + files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() + ) passes .then(() => { @@ -353,7 +349,7 @@ export function listFilesWithRg( } done = true signal?.removeEventListener('abort', onAbort) - killSurvivors('rg list canceled after sibling failure') + killSurvivors('rg list canceled after failure') reject(err instanceof Error ? err : new Error(String(err))) }) }) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index a3b2234ced0..ec2715c9b1a 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -1,3 +1,4 @@ +import { RipgrepSearchDiagnostics } from '../shared/ripgrep-search-diagnostics' /** * Pure helpers and child-process search utilities extracted from fs-handler.ts. * @@ -111,18 +112,15 @@ export function searchWithRg( return Promise.reject(abortSignalReason(signal)) } return new Promise((resolve, reject) => { - const rgArgs = buildRgArgs(query, rootPath, opts) + const rgArgs = buildRgArgs(query, '.', opts) const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false let launchFailureCheck: Promise | null = null - // Why: spawn can throw synchronously on invalid options (e.g. bad cwd), - // which would leak out of the `new Promise` executor and leave the - // promise forever pending. Treat a synchronous throw as a clean - // "no results" fallback, the same way an async 'error' event is handled. const resolvedRgCommand = resolveRelayRipgrepCommand() // Why not spawn a bare name when this is null: on Windows CreateProcessW searches the spawn // cwd -- the user's repo -- before PATH, so a planted rg.exe would run instead. @@ -142,8 +140,8 @@ export function searchWithRg( env, stdio: ['ignore', 'pipe', 'pipe'] }) - } catch { - resolve(finalize(acc)) + } catch (error) { + reject(error) return } @@ -181,9 +179,14 @@ export function searchWithRg( } } - function resolveOnce(): void { + function resolveOnce(code = 0, signal: NodeJS.Signals | null = null): void { if (settle()) { - resolve(finalize(acc)) + const error = diagnostics.failure(code, signal, acc) + if (error) { + reject(error) + } else { + resolve(finalize(acc)) + } } } @@ -235,11 +238,15 @@ export function searchWithRg( } function handleStdoutData(chunk: string): void { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + killSpawnedRipgrepProcess(child) + resolveOnce() + } } - function handleStderrData(): void { - /* drain */ + function handleStderrData(chunk: Buffer): void { + diagnostics.append(chunk) } function handleError(error: Error): void { @@ -248,7 +255,10 @@ export function searchWithRg( settleLaunchFailure(error) return } - resolveOnce() + if (settle()) { + reject(error) + } + killSpawnedRipgrepProcess(child) } function handleClose(code: number | null, signal: NodeJS.Signals | null): void { @@ -261,11 +271,11 @@ export function searchWithRg( settleLaunchFailure() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } child.stdout?.setEncoding('utf-8') diff --git a/src/relay/fs-search-errors-real.test.ts b/src/relay/fs-search-errors-real.test.ts new file mode 100644 index 00000000000..eaf7fb9633c --- /dev/null +++ b/src/relay/fs-search-errors-real.test.ts @@ -0,0 +1,39 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { rgPath } from '@vscode/ripgrep-universal' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' +import { searchWithRg } from './fs-handler-utils' + +let root: string +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-search-errors-')) + configureRelayBundledRipgrep(rgPath) + await writeFile(join(root, 'source.txt'), 'needle\n') +}) +afterEach(async () => { + configureRelayBundledRipgrep(undefined) + await rm(root, { recursive: true, force: true }) +}) + +it('reports an invalid regular expression as an error', async () => { + await expect(searchWithRg(root, '[', { useRegex: true, maxResults: 100 })).rejects.toThrow( + /regex parse error|unclosed character class/ + ) +}) + +it('distinguishes no matches from invalid syntax', async () => { + await expect(searchWithRg(root, 'absent', { maxResults: 100 })).resolves.toEqual({ + files: [], + totalMatches: 0, + truncated: false + }) +}) + +it('retains intentional capped results after stopping the process', async () => { + await writeFile(join(root, 'source.txt'), 'needle\n'.repeat(1000)) + const result = await searchWithRg(root, 'needle', { maxResults: 2 }) + expect(result.totalMatches).toBe(2) + expect(result.truncated).toBe(true) +}) diff --git a/src/relay/opencode-overlay-mirror.ts b/src/relay/opencode-overlay-mirror.ts new file mode 100644 index 00000000000..0bd5e0b0f6c --- /dev/null +++ b/src/relay/opencode-overlay-mirror.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { mirrorEntry } from '../main/pty/overlay-mirror' + +export function mirrorOpenCodeConfig( + sourceDir: string, + overlayDir: string, + excludedPluginEntries: ReadonlySet +): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (excludedPluginEntries.has(pluginEntry.name)) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } +} diff --git a/src/relay/opencode-plugin-selection.ts b/src/relay/opencode-plugin-selection.ts new file mode 100644 index 00000000000..38c7934cfd6 --- /dev/null +++ b/src/relay/opencode-plugin-selection.ts @@ -0,0 +1,39 @@ +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../shared/opencode-cli-version' +import { addWslEnvKeys } from '../shared/wsl-env' + +export function restoreOpenCodeCapabilities(value: unknown): OpenCodeCliCapabilities | undefined { + if ( + !value || + typeof value !== 'object' || + !('version' in value) || + typeof value.version !== 'string' + ) { + return undefined + } + const capabilities = getOpenCodeCliCapabilities(value.version) + return capabilities.pluginApi === 'unknown' ? undefined : capabilities +} + +export function applyOpenCodePluginSelection( + env: Record, + envToDelete: string[], + capabilities: OpenCodeCliCapabilities | null | undefined, + wsl: boolean +): void { + delete env.ORCA_OPENCODE_PLUGIN_API + if (!capabilities || capabilities.pluginApi === 'unknown') { + return + } + env.ORCA_OPENCODE_PLUGIN_API = capabilities.pluginApi + for (let index = envToDelete.length - 1; index >= 0; index -= 1) { + if (envToDelete[index] === 'ORCA_OPENCODE_PLUGIN_API') { + envToDelete.splice(index, 1) + } + } + if (wsl) { + addWslEnvKeys(env, ['ORCA_OPENCODE_PLUGIN_API']) + } +} diff --git a/src/relay/opencode-startup-prompt-install.test.ts b/src/relay/opencode-startup-prompt-install.test.ts new file mode 100644 index 00000000000..71cb373511e --- /dev/null +++ b/src/relay/opencode-startup-prompt-install.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PluginOverlayManager } from './plugin-overlay' +import { createInstallPluginsHandler } from './wsl-install-plugins-handler' + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relay-prompt-install-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) +const promptPath = (config: string) => + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js') +describe('execution-host startup prompt installation', () => { + it('caches prompt source independently and revokes future installs with an empty source', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + expect(manager.hasOpenCodeSource()).toBe(false) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + manager.setSources({ opencodePluginSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + expect(readFileSync(promptPath(config), 'utf8')).toBe('prompt source') + manager.setSources({ opencodeStartupPromptSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, join(root, 'not-installed'))).toBe(false) + expect(existsSync(join(root, 'not-installed'))).toBe(false) + }) + it('materializes a prompt-only overlay without overwriting a same-named user plugin', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'), { recursive: true }) + writeFileSync(promptPath(config), 'user collision') + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + const overlay = manager.materializeOpenCode('pane', config) + if (!overlay) { + throw new Error('Missing prompt overlay') + } + expect(readFileSync(promptPath(overlay), 'utf8')).toBe('prompt source') + expect(readFileSync(promptPath(config), 'utf8')).toBe('user collision') + expect(readFileSync(join(overlay, 'opencode.json'), 'utf8')).toContain('user/model') + expect(existsSync(join(overlay, 'plugins', 'orca-opencode-status.js'))).toBe(false) + }) + it('installs through WSL with both status sources disabled and preserves explicit config', () => { + const config = join(root, 'custom') + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { + HOME: root, + OPENCODE_CONFIG_DIR: config + }) + const result = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: '', + opencode2PluginSource: '' + }) + expect(result.installed).toMatchObject({ + opencodeStartupPrompt: true, + opencode: false, + opencode2: false + }) + expect(result.overlayDirs).toEqual({}) + expect(readFileSync(promptPath(config), 'utf8')).toBe('first') + install({ opencodeStartupPromptSource: 'second' }) + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + }) + it('refreshes prompt source in both cached status overlays without rebuilding them', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { HOME: root }) + const first = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: 'status v1', + opencode2PluginSource: 'status v2' + }) + const second = install({ opencodeStartupPromptSource: 'second' }) + expect(second.overlayDirs).toEqual(first.overlayDirs) + for (const config of [second.overlayDirs.opencode, second.overlayDirs.opencode2]) { + if (!config) { + throw new Error('Missing status overlay') + } + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + } + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 6d78918c46e..08c9f772bed 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -1,3 +1,9 @@ +import { mirrorOpenCodeConfig } from './opencode-overlay-mirror' +import { + writeOpenCodeStartupPromptPlugin, + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY +} from '../shared/opencode-startup-prompt-install' +import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // Why: relay-side equivalent of Orca's local agent integration installers. // OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed @@ -17,19 +23,11 @@ import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - statSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' -import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { safeRemoveOverlay } from '../main/pty/overlay-mirror' import type { PiAgentKind } from '../shared/pi-agent-kind' import { installOpenCodePluginInCanonicalConfig, @@ -52,9 +50,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' // Orca's own entries (either major, file and TUI copy) are never mirrored from user config. -const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set( - [OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) -) +const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set([ + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + ...[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) +]) const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' @@ -87,6 +86,7 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { + opencodeStartupPromptSource?: string /** Empty string revokes future installs; omission preserves the cached source. */ opencodePluginSource?: string /** Source body of OpenCode 2's status plugin. */ @@ -118,6 +118,7 @@ export function getRelayOpenCodePluginPath( ) } export class PluginOverlayManager { + private opencodeStartupPromptSource: string | null = null private opencodePluginSource: string | null = null private opencode2PluginSource: string | null = null private piExtensionSources: Record = { @@ -147,6 +148,9 @@ export class PluginOverlayManager { * process start. Future PTYs pick up the refreshed source when the relay * writes plugin/extension files before spawn. */ setSources(sources: PluginSources): void { + if (typeof sources.opencodeStartupPromptSource === 'string') { + this.opencodeStartupPromptSource = sources.opencodeStartupPromptSource + } if (typeof sources.opencodePluginSource === 'string') { this.opencodePluginSource = sources.opencodePluginSource } @@ -178,38 +182,6 @@ export class PluginOverlayManager { const source = this.piExtensionSources[kind] return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - if (entry.name === 'plugins') { - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - isLinkPointingToDir = false - } - } - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - const overlayPluginsDir = join(overlayDir, 'plugins') - mkdirSync(overlayPluginsDir, { recursive: true }) - for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) { - continue - } - mirrorEntry( - join(resolvedSource, pluginEntry.name), - join(overlayPluginsDir, pluginEntry.name) - ) - } - continue - } - } - mirrorEntry(sourcePath, join(overlayDir, entry.name)) - } - } private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) @@ -230,7 +202,7 @@ export class PluginOverlayManager { agent: 'opencode' | 'opencode2' = 'opencode' ): string | null { const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource - if (!source || !isUsableId(id)) { + if ((!source && !this.opencodeStartupPromptSource) || !isUsableId(id)) { return null } const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE @@ -246,9 +218,14 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir) + mirrorOpenCodeConfig(existingConfigDir, dir, ORCA_OPENCODE_PLUGIN_ENTRIES) + } + if (source) { + this.writeOpenCodePlugin(dir, pluginFileName, source) + } + if (this.opencodeStartupPromptSource) { + writeOpenCodeStartupPromptPlugin(dir, this.opencodeStartupPromptSource, 'overlay') } - this.writeOpenCodePlugin(dir, pluginFileName, source) return dir } catch (err) { process.stderr.write( @@ -258,6 +235,29 @@ export class PluginOverlayManager { } } + installOpenCodeStartupPromptPlugin( + environment: NodeJS.ProcessEnv | Record, + configDir?: string + ): boolean { + if (!this.opencodeStartupPromptSource) { + return false + } + try { + writeOpenCodeStartupPromptPlugin( + configDir ?? + environment.OPENCODE_CONFIG_DIR ?? + resolveOpenCodeConfigDirectory(environment, this.homeDir), + this.opencodeStartupPromptSource + ) + return true + } catch (error) { + process.stderr.write( + `[plugin-overlay] failed to install OpenCode startup prompt: ${error instanceof Error ? error.message : String(error)}\n` + ) + return false + } + } + hasOpenCode2Source(): boolean { return this.hasOpenCodeSource('opencode2') } diff --git a/src/relay/pty-handler-opencode-revive.test.ts b/src/relay/pty-handler-opencode-revive.test.ts new file mode 100644 index 00000000000..1c73f8be1fe --- /dev/null +++ b/src/relay/pty-handler-opencode-revive.test.ts @@ -0,0 +1,142 @@ +import './mock-descendant-sweep' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { applyOpenCodePluginSelection } from './opencode-plugin-selection' +import type { PtyHandler } from './pty-handler' +import { + beginPtyHandlerTest, + createMockDispatcher, + createTestPtyHandler, + endPtyHandlerTest, + testPtyId, + type MockDispatcher +} from './pty-handler-test-harness' + +const mocks = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + probe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn })) +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe +})) +vi.mock('../main/opencode/opencode-launch-capabilities', () => ({ + probeOpenCodeLaunchCapabilities: mocks.probe +})) + +let dispatcher: MockDispatcher +let handler: PtyHandler +let originalPlatform: PropertyDescriptor | undefined + +beforeEach(() => { + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest(mocks)) + mocks.probe.mockReset() +}) + +afterEach(async () => endPtyHandlerTest(handler, originalPlatform)) + +function spawnedEnvironment(): Record { + const options: unknown = mocks.mockPtySpawn.mock.calls.at(-1)?.[2] + if ( + !options || + typeof options !== 'object' || + !('env' in options) || + !options.env || + typeof options.env !== 'object' + ) { + throw new Error('Expected a PTY spawn environment') + } + return Object.fromEntries(Object.entries(options.env)) +} + +async function serialize(): Promise { + const state = await dispatcher.callRequest('pty.serialize', { ids: [testPtyId(1)] }) + if (typeof state !== 'string') { + throw new Error('Expected serialized PTY state') + } + return state +} + +async function restartHandler(): Promise { + await handler.dispose({ waitForPhysicalExit: false }) + dispatcher = createMockDispatcher() + handler = createTestPtyHandler(dispatcher) + mocks.mockPtySpawn.mockClear() +} + +it.each(['1.18.32', '2.0.16'])( + 'preserves host-selected plugin exports through two relay revives on %s', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + mocks.probe.mockResolvedValue(capabilities) + await dispatcher.callRequest('pty.spawn', { + cwd: tmpdir(), + command: 'opencode --standalone', + launchAgent: 'opencode', + env: { ORCA_PANE_KEY: 'tab-oc:leaf', ORCA_OPENCODE_PLUGIN_API: 'untrusted' }, + envToDelete: ['ORCA_OPENCODE_PLUGIN_API', 'ORCA_OPENCODE_PLUGIN_API'] + }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + const state = await serialize() + expect(JSON.parse(state)).toMatchObject([ + { openCodeCapabilities: capabilities, envToDelete: [] } + ]) + + await restartHandler() + await dispatcher.callRequest('pty.revive', { state }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + const second = await serialize() + await restartHandler() + await dispatcher.callRequest('pty.revive', { state: second }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + expect(mocks.probe).toHaveBeenCalledOnce() + } +) + +it.each([ + undefined, + { version: 'garbage', pluginApi: 'v1' }, + { version: '3.0.0', pluginApi: 'v1' }, + { version: 2, pluginApi: 'v1' } +])('keeps legacy or unverifiable serialized selection unset: %j', async (openCodeCapabilities) => { + handler.addEnvAugmenter(() => ({ ORCA_OPENCODE_PLUGIN_API: 'v1' })) + await dispatcher.callRequest('pty.revive', { + state: JSON.stringify([ + { + id: testPtyId(1), + pid: process.pid, + cols: 80, + rows: 24, + cwd: tmpdir(), + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) + } + ]) + }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBeUndefined() + expect(mocks.probe).not.toHaveBeenCalled() +}) + +it('carries a selected API into WSL without dropping other forwarded keys', () => { + const env = { WSLENV: 'XDG_DATA_HOME/p', ORCA_OPENCODE_PLUGIN_API: 'untrusted' } + const envToDelete = ['ORCA_OPENCODE_PLUGIN_API', 'OTHER_ENV'] + applyOpenCodePluginSelection(env, envToDelete, getOpenCodeCliCapabilities('1.18.32'), true) + expect(env).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'XDG_DATA_HOME/p:ORCA_OPENCODE_PLUGIN_API' + }) + expect(envToDelete).toEqual(['OTHER_ENV']) +}) diff --git a/src/relay/pty-handler-owner-inventory-clone-budget.test.ts b/src/relay/pty-handler-owner-inventory-clone-budget.test.ts new file mode 100644 index 00000000000..bb283e47992 --- /dev/null +++ b/src/relay/pty-handler-owner-inventory-clone-budget.test.ts @@ -0,0 +1,398 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as CryptoModule from 'node:crypto' +import type * as SnapshotModule from '../shared/process-table-snapshot-reader' +import { RelayDispatcher } from './dispatcher' +import { encodeJsonRpcFrame, parseJsonRpcMessage, type JsonRpcResponse } from './protocol' +import { PtyHandler } from './pty-handler' +import { ClaimedAgentPtyOwnerRegistry } from '../shared/claimed-agent-pty-owner' +import { + isAgentSessionClaimedSpawnResult, + isAgentSessionOwnerBinding, + type AgentSessionOwnerBinding +} from '../shared/agent-session-host-authority' +import * as ptyShell from './pty-shell-utils' + +const mocks = vi.hoisted(() => ({ spawn: vi.fn(), snapshots: vi.fn(), uuid: 0 })) +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + randomUUID: () => `00000000-0000-4000-8000-${String(++mocks.uuid).padStart(12, '0')}` + } +}) +vi.mock('node-pty', () => ({ spawn: mocks.spawn })) +vi.mock('../shared/process-table-snapshot-reader', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, getStrictProcessTableSnapshotWithAge: mocks.snapshots } +}) +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +type InventoryRow = { + id: string + incarnationId: string + agentSessionOwners?: AgentSessionOwnerBinding[] +} + +function inventoryRows(value: unknown): InventoryRow[] { + if (!Array.isArray(value)) { + throw new Error('Inventory result must be an array') + } + return value.map((row: unknown) => { + if ( + typeof row !== 'object' || + row === null || + !('id' in row) || + typeof row.id !== 'string' || + !('incarnationId' in row) || + typeof row.incarnationId !== 'string' + ) { + throw new Error('Inventory row identity is missing') + } + const owners = 'agentSessionOwners' in row ? row.agentSessionOwners : undefined + if ( + owners !== undefined && + (!Array.isArray(owners) || !owners.every(isAgentSessionOwnerBinding)) + ) { + throw new Error('Inventory owners are malformed') + } + return { + id: row.id, + incarnationId: row.incarnationId, + ...(owners ? { agentSessionOwners: owners } : {}) + } + }) +} + +describe('relay owner inventory clone budget', () => { + let dispatcher: RelayDispatcher + let handler: PtyHandler + let originalPlatform: PropertyDescriptor | undefined + let requestId = 0 + const frames: Buffer[] = [] + + async function response(method: string, params: Record = {}) { + const id = ++requestId + const before = frames.length + dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)) + for (let turn = 0; turn < 50 && frames.length === before; turn++) { + await Promise.resolve() + } + expect(frames).toHaveLength(before + 1) + const frame = frames[before] + const message = parseJsonRpcMessage(frame.subarray(13)) + return { message, frame } + } + + async function request(method: string, params: Record = {}) { + const { message, frame } = await response(method, params) + const id = requestId + if (!('id' in message) || message.id !== id || 'method' in message || !('result' in message)) { + throw new Error(`Unexpected RPC response: ${JSON.stringify(message)}`) + } + return { result: message.result, frame } + } + + beforeEach(() => { + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + vi.useFakeTimers() + vi.spyOn(Date, 'now').mockReturnValue(1_000_000) + vi.spyOn(ptyShell, 'isProcessAlive').mockReturnValue(true) + mocks.snapshots.mockReset().mockResolvedValue({ rows: [], capturedAgeMs: 0 }) + mocks.uuid = 0 + mocks.spawn.mockReset().mockImplementation(() => ({ + pid: process.pid, + process: 'zsh', + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + })) + requestId = 0 + frames.length = 0 + dispatcher = new RelayDispatcher((frame) => { + frames.push(Buffer.from(frame)) + return true + }) + handler = new PtyHandler(dispatcher, undefined, 'owner-budget') + }) + + afterEach(async () => { + await handler.dispose({ waitForPhysicalExit: false }) + dispatcher.dispose() + vi.restoreAllMocks() + vi.useRealTimers() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it.each([false, true, undefined])( + 'samples the complete frame clock before cloning, evidence=%s', + async (includeForegroundProcessEvidence) => { + const spawned = await request('pty.spawn', { + cwd: process.cwd(), + env: { ORCA_PANE_KEY: 'clock-pane' }, + agentSessionEnsure: { + claim: { + digestVersion: 1, + keyId: 'clock-key', + identityDigest: 'a'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' + }, + surface: { + worktreeId: 'clock-folder', + tabId: '11111111-1111-4111-8111-111111111111', + leafId: '22222222-2222-4222-8222-222222222222', + terminalHandle: 'term_clock' + } + } + }) + const identity = inventoryRows([spawned.result])[0] + if ( + typeof spawned.result !== 'object' || + spawned.result === null || + !('agentSessionEnsure' in spawned.result) || + !isAgentSessionClaimedSpawnResult(spawned.result.agentSessionEnsure) + ) { + throw new Error('Claimed spawn receipt is missing') + } + let now = 1_000_017 + vi.spyOn(Date, 'now').mockImplementation(() => now) + const originalList = ClaimedAgentPtyOwnerRegistry.prototype.listForPty + vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty').mockImplementation(function ( + this: ClaimedAgentPtyOwnerRegistry, + id: string + ) { + const owners = originalList.call(this, id) + now += 20 + return owners + }) + for (let iteration = 0; iteration < 2; iteration++) { + now = 1_000_017 + iteration * 100 + const receipt = await request( + 'pty.listProcesses', + includeForegroundProcessEvidence === undefined ? {} : { includeForegroundProcessEvidence } + ) + const full = [ + { + id: identity.id, + incarnationId: identity.incarnationId, + cwd: process.cwd(), + title: 'zsh', + hostAgeMs: 17 + iteration * 100, + paneBound: true, + ...(includeForegroundProcessEvidence !== false + ? { + foregroundProcessEvidence: { + authorityGeneration: 'owner-budget', + observationEpoch: iteration + 1, + capturedAgeMs: 0, + verdict: 'unverifiable', + reason: 'root_missing' + } + } + : {}), + agentSessionOwners: [spawned.result.agentSessionEnsure.owner] + } + ] + expect(receipt.result).toEqual(full) + expect( + receipt.frame.equals( + encodeJsonRpcFrame( + { jsonrpc: '2.0', id: requestId, result: full }, + requestId, + requestId + ) + ) + ).toBe(true) + } + } + ) + + it('publishes the clock error before attempting an owner clone', async () => { + await request('pty.spawn', { cwd: process.cwd() }) + const list = vi + .spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty') + .mockImplementation(() => { + throw new Error('owner-clone-failed') + }) + // Isolate the age read after the decoder, receive and inventory clock stamps. + const clock = vi + .spyOn(Date, 'now') + .mockReturnValue(1_000_000) + .mockReturnValueOnce(1_000_000) + .mockReturnValueOnce(1_000_000) + .mockReturnValueOnce(1_000_000) + .mockImplementationOnce(() => { + throw new Error('host-clock-failed') + }) + try { + const receipt = await response('pty.listProcesses', { + includeForegroundProcessEvidence: false + }) + const expected: JsonRpcResponse = { + jsonrpc: '2.0', + id: requestId, + error: { code: -32000, message: 'host-clock-failed' } + } + expect(receipt.message).toEqual(expected) + expect(receipt.frame.equals(encodeJsonRpcFrame(expected, requestId, requestId))).toBe(true) + expect(list).not.toHaveBeenCalled() + } finally { + clock.mockReturnValue(1_000_000) + } + }) + + it.each([false, true, undefined])( + 'keeps complete RPC frames and fresh owners, evidence=%s', + async (includeForegroundProcessEvidence) => { + const withEvidence = includeForegroundProcessEvidence !== false + const expected: Record[] = [] + const ensure = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'ensure') + let firstOwner: AgentSessionOwnerBinding | null = null + const ownedCount = 8 + const plainCount = 3 + for (let index = 0; index < ownedCount + plainCount; index++) { + const claim = { + digestVersion: 1, + keyId: `key${index}`, + identityDigest: 'a'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' + } + const surface = { + worktreeId: `folder${index}`, + tabId: '11111111-1111-4111-8111-111111111111', + leafId: '22222222-2222-4222-8222-222222222222', + terminalHandle: `term_owner${index}` + } + const spawned = await request('pty.spawn', { + cwd: process.cwd(), + env: { ORCA_PANE_KEY: `pane${index}` }, + ...(index < ownedCount ? { agentSessionEnsure: { claim, surface } } : {}) + }) + const identity = inventoryRows([spawned.result])[0] + let owner: AgentSessionOwnerBinding | null = null + if (index < ownedCount) { + if ( + typeof spawned.result !== 'object' || + spawned.result === null || + !('agentSessionEnsure' in spawned.result) || + !isAgentSessionClaimedSpawnResult(spawned.result.agentSessionEnsure) + ) { + throw new Error('Claimed spawn receipt is missing') + } + owner = spawned.result.agentSessionEnsure.owner + firstOwner ??= owner + } + expected.push({ + id: identity.id, + incarnationId: identity.incarnationId, + cwd: process.cwd(), + title: 'zsh', + hostAgeMs: 0, + paneBound: true, + ...(owner ? { agentSessionOwners: [owner] } : {}) + }) + } + const registry = ensure.mock.contexts[0] + if (!(registry instanceof ClaimedAgentPtyOwnerRegistry) || !firstOwner) { + throw new Error('Actual claimed registry was not used') + } + const secondOwner: AgentSessionOwnerBinding = { + ...firstOwner, + claim: { ...firstOwner.claim, keyId: 'second-owner' }, + surface: { ...firstOwner.surface, terminalHandle: 'term_second' }, + generation: 'second-generation' + } + registry.register(secondOwner) + expected[0].agentSessionOwners = [firstOwner, secondOwner] + const list = vi.spyOn(ClaimedAgentPtyOwnerRegistry.prototype, 'listForPty') + let previous: InventoryRow[] | null = null + let previousClone: AgentSessionOwnerBinding[] | null = null + for (let iteration = 0; iteration < 10; iteration++) { + const firstCloneIndex = list.mock.results.length + const response = await request( + 'pty.listProcesses', + includeForegroundProcessEvidence === undefined ? {} : { includeForegroundProcessEvidence } + ) + const full = expected.map(({ agentSessionOwners, ...row }) => ({ + ...row, + ...(withEvidence + ? { + foregroundProcessEvidence: { + authorityGeneration: 'owner-budget', + observationEpoch: iteration + 1, + capturedAgeMs: 0, + verdict: 'unverifiable', + reason: 'root_missing' + } + } + : {}), + ...(agentSessionOwners ? { agentSessionOwners } : {}) + })) + expect(response.result).toEqual(full) + const expectedFrame = encodeJsonRpcFrame( + { jsonrpc: '2.0', id: requestId, result: full }, + requestId, + requestId + ) + expect(response.frame.equals(expectedFrame)).toBe(true) + const rows = inventoryRows(response.result) + const firstClone: unknown = list.mock.results[firstCloneIndex].value + if (!Array.isArray(firstClone) || !firstClone.every(isAgentSessionOwnerBinding)) { + throw new Error('Actual registry clone is malformed') + } + if (previousClone) { + expect(firstClone).not.toBe(previousClone) + expect(firstClone[0]).not.toBe(previousClone[0]) + expect(firstClone[0].claim).not.toBe(previousClone[0].claim) + expect(firstClone[0].surface).not.toBe(previousClone[0].surface) + } + if (previous) { + expect(rows[0].agentSessionOwners).not.toBe(previous[0].agentSessionOwners) + expect(rows[0].agentSessionOwners?.[0]).not.toBe(previous[0].agentSessionOwners?.[0]) + expect(rows[0].agentSessionOwners?.[0].claim).not.toBe( + previous[0].agentSessionOwners?.[0].claim + ) + expect(rows[0].agentSessionOwners?.[0].surface).not.toBe( + previous[0].agentSessionOwners?.[0].surface + ) + } + const owner = rows[0].agentSessionOwners?.[0] + if (!owner) { + throw new Error('Expected first owner') + } + if (iteration === 4) { + registry.release(secondOwner.ptyId, secondOwner.generation) + expected[0].agentSessionOwners = [firstOwner] + } + owner.claim.keyId = 'mutated-client-copy' + owner.surface.terminalHandle = 'term_mutated' + firstClone[0].claim.keyId = 'mutated-published-clone' + firstClone[0].surface.terminalHandle = 'term_mutated_clone' + previousClone = firstClone + previous = rows + } + expect(mocks.snapshots).toHaveBeenCalledTimes(withEvidence ? 10 : 0) + const copiedOwners = list.mock.results.reduce((count, result) => { + const owners: unknown = result.value + if (!Array.isArray(owners) || !owners.every(isAgentSessionOwnerBinding)) { + throw new Error('Actual registry clone is malformed') + } + return count + owners.length + }, 0) + expect(copiedOwners).toBe(85) + expect(list).toHaveBeenCalledTimes((ownedCount + plainCount) * 10) + } + ) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 1311be13b67..b96781818e7 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -5,6 +5,13 @@ import { restoreManagedDataAccountEnvironment } from '../shared/managed-data-acc import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' +import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities' +import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { + applyOpenCodePluginSelection, + restoreOpenCodeCapabilities +} from './opencode-plugin-selection' +import { resolveCommandPathForRelay } from './preflight-handler' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -27,6 +34,13 @@ 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 { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../shared/opencode-startup-prompt' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -218,6 +232,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | } type ManagedPty = { + openCodeCapabilities?: OpenCodeCliCapabilities freebuffStatus?: FreebuffStatusProjection id: string incarnationId: string @@ -278,6 +293,7 @@ type ManagedPty = { } type RelayAgentSessionCreateResult = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string incarnationId: string replay?: string @@ -449,6 +465,7 @@ type PtyProcessSummary = { } type SerializedPtyEntry = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string pid: number cols: number @@ -1954,6 +1971,9 @@ export class PtyHandler { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, + ...(result.disposition === 'created' && managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), ...(sourceActivation ? { sourceActivation } : {}), ...(adoptedReplay ? { replay: adoptedReplay } : {}), ...(managed.shellReadyArmed !== undefined @@ -1982,6 +2002,7 @@ export class PtyHandler { incarnationId: string sourceActivation?: PtySourceReceivingActivation shellReadyArmed?: boolean + openCodeCapabilities?: OpenCodeCliCapabilities }> { const pty = await this.loadPty() if (!pty) { @@ -2028,6 +2049,32 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + // Relay input streams lack driving-input provenance, so native intent is unavailable. + for (const key of [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV + ]) { + delete spawnEnv[key] + } + const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: launchAgent, + env: spawnEnv, + cwd, + hostIdentity: `relay:${process.platform}`, + resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), + ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) + }) + applyOpenCodePluginSelection( + spawnEnv, + envToDelete, + openCodeCapabilities, + isRelayWslShell(shell) + ) await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -2129,6 +2176,7 @@ export class PtyHandler { const ownerClientInstanceId = context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) const managed: ManagedPty = { + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(launchAgent === 'freebuff' ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } : {}), @@ -2208,7 +2256,8 @@ export class PtyHandler { id, incarnationId: managed.incarnationId, ...(sourceActivation ? { sourceActivation } : {}), - shellReadyArmed: rendererShellReadySupported + shellReadyArmed: rendererShellReadySupported, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) } } @@ -3055,12 +3104,14 @@ export class PtyHandler { } ) : undefined + const hostAgeMs = Math.max(0, Date.now() - managed.createdAt) + const agentSessionOwners = this.agentSessionOwners.listForPty(id) results.push({ id, incarnationId: managed.incarnationId, cwd: managed.initialCwd, title, - hostAgeMs: Math.max(0, Date.now() - managed.createdAt), + hostAgeMs, paneBound: Boolean(managed.paneKey ?? managed.attachIdentity?.paneKey), ...(managed.ownerClientInstanceId ? { ownerClientInstanceId: managed.ownerClientInstanceId } @@ -3068,9 +3119,7 @@ export class PtyHandler { ...(managed.worktreeId ? { worktreeId: managed.worktreeId } : {}), ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}), ...(foregroundProcessEvidence ? { foregroundProcessEvidence } : {}), - ...(this.agentSessionOwners.listForPty(id).length - ? { agentSessionOwners: this.agentSessionOwners.listForPty(id) } - : {}) + ...(agentSessionOwners.length ? { agentSessionOwners } : {}) }) } return results @@ -3097,6 +3146,9 @@ export class PtyHandler { worktreeId: managed.worktreeId, ...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}), envToDelete: managed.envToDelete, + ...(managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded, ...(managed.historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), // Why serialized: revive re-spawns the shell, and without these a WSL @@ -3191,6 +3243,8 @@ export class PtyHandler { { id: entry.id, paneKey: entry.paneKey, shell }, envToDelete ) + const openCodeCapabilities = restoreOpenCodeCapabilities(entry.openCodeCapabilities) + applyOpenCodePluginSelection(spawnEnv, envToDelete, openCodeCapabilities, wslShell) if ( historyIsolationEnabled && entry.worktreeId && @@ -3268,6 +3322,7 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), shellPath: shell, // Why re-stored: a revived pane can be serialized again, and losing the diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index c8d699f36be..82ad3a7fb85 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' import { BASH_FEATURE_CHANNEL_BLOCK, @@ -73,6 +74,7 @@ fi [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +${ORCA_CLI_POSIX_PATH_RESTORE} ${getPosixOmpShellWrapper()} ${getPosixCodexShellLaunchPreflight()}${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index e0d9a95ae68..4ee9113919a 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -186,17 +186,20 @@ export class RelayAgentHookRuntime { })) registerManagedHookInstaller(this.dispatcher) this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) this.pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -213,8 +216,12 @@ export class RelayAgentHookRuntime { installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) } } + const startupPromptInstalled = this.pluginOverlay.installOpenCodeStartupPromptPlugin( + process.env + ) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: this.pluginOverlay.hasOpenCodeSource(), opencode2: this.pluginOverlay.hasOpenCode2Source(), pi: this.pluginOverlay.hasPiSource('pi'), diff --git a/src/relay/relay-bundled-ripgrep.test.ts b/src/relay/relay-bundled-ripgrep.test.ts index 5f667ad1429..93c4e56490f 100644 --- a/src/relay/relay-bundled-ripgrep.test.ts +++ b/src/relay/relay-bundled-ripgrep.test.ts @@ -104,7 +104,7 @@ describe('relay bundled ripgrep', () => { return child } const child = createProcess(42) - succeedWith(child, 'src/index.ts\n') + succeedWith(child, 'src/index.ts\0') return child }) diff --git a/src/relay/relay-bundled-ripgrep.ts b/src/relay/relay-bundled-ripgrep.ts index 787176e3f42..5c2dd89f3be 100644 --- a/src/relay/relay-bundled-ripgrep.ts +++ b/src/relay/relay-bundled-ripgrep.ts @@ -4,12 +4,13 @@ * the answer whenever that binary is absent or cannot launch on this host. */ import { existsSync, statSync } from 'node:fs' -import { delimiter, join, win32 } from 'node:path' +import { win32 } from 'node:path' import { isRipgrepSpawnCwdUsable, isTransientRipgrepSpawnError } from '../shared/ripgrep-process-availability' import { relayLogLine } from './relay-diagnostic-log' +import { buildRelayCommandEnv } from './relay-command-env' export const PATH_RIPGREP_COMMAND = 'rg' @@ -31,13 +32,13 @@ export function isDriveRootedWindowsPath(dir: string): boolean { // Why only rg.exe, though libuv honours %PATHEXT%: `.bat`/`.cmd` shims are not spawnable without // `shell: true`, and every ripgrep installer (winget, choco, scoop, cargo) lays down rg.exe. -function resolveWindowsPathRipgrep(): string | null { - for (const entry of (process.env.PATH ?? '').split(delimiter)) { +function resolveWindowsPathRipgrep(path: string): string | null { + for (const entry of path.split(';')) { const dir = entry.replace(/^"|"$/g, '').trim() if (!dir || !isDriveRootedWindowsPath(dir)) { continue } - const candidate = join(dir, 'rg.exe') + const candidate = win32.join(dir, 'rg.exe') try { if (statSync(candidate).isFile()) { return candidate @@ -49,7 +50,8 @@ function resolveWindowsPathRipgrep(): string | null { return null } -let windowsPathRipgrep: string | null | undefined +let windowsPathRipgrep: { path: string; command: string | null; expiresAt: number } | undefined +const WINDOWS_PATH_MISS_RETRY_MS = 60_000 let bundledRipgrepPath: string | null = null // Why a back-off, not forever: Windows AV often locks a just-installed rg.exe for its first spawns. @@ -69,13 +71,23 @@ export function pathRipgrepCommand(): string | null { if (process.platform !== 'win32') { return PATH_RIPGREP_COMMAND } - // Why the explicit undefined check and not `??=`: a miss resolves to null, which is nullish, so - // `??=` would re-walk every PATH entry on each call -- and a miss is the expensive case, since - // it stats every directory instead of stopping at the first hit. - if (windowsPathRipgrep === undefined) { - windowsPathRipgrep = resolveWindowsPathRipgrep() + const env = buildRelayCommandEnv() + const path = env.PATH ?? env.Path ?? '' + const now = Date.now() + // Cache the effective PATH; retry misses so an install can become visible without restarting. + if ( + !windowsPathRipgrep || + windowsPathRipgrep.path !== path || + now >= windowsPathRipgrep.expiresAt + ) { + const command = resolveWindowsPathRipgrep(path) + windowsPathRipgrep = { + path, + command, + expiresAt: command === null ? now + WINDOWS_PATH_MISS_RETRY_MS : Infinity + } } - return windowsPathRipgrep + return windowsPathRipgrep.command } /** Null means this host has no usable rg, so the caller falls back to git/readdir. */ diff --git a/src/relay/relay-ripgrep-cwd-env.test.ts b/src/relay/relay-ripgrep-cwd-env.test.ts index 38734ca63e6..c30590b4ad1 100644 --- a/src/relay/relay-ripgrep-cwd-env.test.ts +++ b/src/relay/relay-ripgrep-cwd-env.test.ts @@ -16,7 +16,7 @@ describe.runIf(process.platform !== 'win32')( writeFileSync(join(dir, 'found.txt'), 'needle') const cargo = join(dir, 'cargo') mkdirSync(join(cargo, 'bin'), { recursive: true }) - writeFileSync(join(cargo, 'bin', 'rg'), '#!/bin/sh\necho found.txt\n', { mode: 0o755 }) + writeFileSync(join(cargo, 'bin', 'rg'), "#!/bin/sh\nprintf 'found.txt\\0'\n", { mode: 0o755 }) vi.stubEnv('PATH', join(dir, 'empty-path')) vi.stubEnv('CARGO_HOME', cargo) configureRelayBundledRipgrep(undefined) diff --git a/src/relay/relay-windows-path-ripgrep-cache.test.ts b/src/relay/relay-windows-path-ripgrep-cache.test.ts new file mode 100644 index 00000000000..bea305bd741 --- /dev/null +++ b/src/relay/relay-windows-path-ripgrep-cache.test.ts @@ -0,0 +1,79 @@ +import type * as fs from 'node:fs' +import { statSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { pathRipgrepCommand, resetRelayRipgrepPathCacheForTests } from './relay-bundled-ripgrep' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, statSync: vi.fn(actual.statSync) } +}) + +const originalPlatform = process.platform +const present = new Set() +const statMock = vi.mocked(statSync) +const fileStats = statSync(new URL(import.meta.url)) + +describe('Windows relay ripgrep effective PATH cache', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.stubEnv('Path', undefined) + vi.stubEnv('PATH', 'C:\\tools') + vi.stubEnv('CARGO_HOME', 'C:\\cargo') + vi.useFakeTimers() + vi.setSystemTime(1000) + present.clear() + resetRelayRipgrepPathCacheForTests() + statMock.mockImplementation((path) => { + if (!present.has(String(path))) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + } + return fileStats + }) + statMock.mockClear() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + vi.unstubAllEnvs() + vi.useRealTimers() + statMock.mockReset() + resetRelayRipgrepPathCacheForTests() + }) + + it('finds rg in the Cargo fallback appended to the command environment', () => { + present.add('C:\\cargo\\bin\\rg.exe') + expect(pathRipgrepCommand()).toBe('C:\\cargo\\bin\\rg.exe') + const calls = statMock.mock.calls.length + expect(pathRipgrepCommand()).toBe('C:\\cargo\\bin\\rg.exe') + expect(statMock).toHaveBeenCalledTimes(calls) + }) + + it('supports mixed-case Path and never probes cwd-relative entries', () => { + vi.stubEnv('PATH', undefined) + vi.stubEnv('Path', '.;node_modules\\.bin;\\tools;C:tools;C:\\safe') + present.add('C:\\safe\\rg.exe') + expect(pathRipgrepCommand()).toBe('C:\\safe\\rg.exe') + expect(statMock.mock.calls.map(([path]) => path)).toEqual(['C:\\safe\\rg.exe']) + }) + + it('invalidates both successful and missing resolutions when effective PATH changes', () => { + expect(pathRipgrepCommand()).toBeNull() + vi.stubEnv('CARGO_HOME', 'D:\\cargo') + present.add('D:\\cargo\\bin\\rg.exe') + expect(pathRipgrepCommand()).toBe('D:\\cargo\\bin\\rg.exe') + vi.stubEnv('PATH', 'E:\\tools') + present.add('E:\\tools\\rg.exe') + expect(pathRipgrepCommand()).toBe('E:\\tools\\rg.exe') + }) + + it('retries cached misses after a bounded delay without rescanning on each call', () => { + expect(pathRipgrepCommand()).toBeNull() + const calls = statMock.mock.calls.length + present.add('C:\\tools\\rg.exe') + vi.advanceTimersByTime(59_999) + expect(pathRipgrepCommand()).toBeNull() + expect(statMock).toHaveBeenCalledTimes(calls) + vi.advanceTimersByTime(1) + expect(pathRipgrepCommand()).toBe('C:\\tools\\rg.exe') + }) +}) diff --git a/src/relay/relay-windows-path-ripgrep.test.ts b/src/relay/relay-windows-path-ripgrep.test.ts index a6dee0e14e8..aa8601fdab5 100644 --- a/src/relay/relay-windows-path-ripgrep.test.ts +++ b/src/relay/relay-windows-path-ripgrep.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { configureRelayBundledRipgrep, isDriveRootedWindowsPath, @@ -9,6 +9,9 @@ import { resetRelayRipgrepPathCacheForTests } from './relay-bundled-ripgrep' +// These tests isolate PATH safety; effective-environment coverage lives in the cache suite. +vi.mock('./relay-command-env', () => ({ buildRelayCommandEnv: () => ({ ...process.env }) })) + const originalPlatform = process.platform const originalPath = process.env.PATH @@ -68,7 +71,7 @@ describe('relay PATH ripgrep resolution', () => { // while the spawn -- running with the user's repo as cwd -- resolves them against the repo's. it('skips rooted PATH entries that carry no drive', () => { setPlatform('win32') - process.env.PATH = `\\tools${delimiter}/tools${delimiter}C:tools` + process.env.PATH = '\\tools;/tools;C:tools' resetRelayRipgrepPathCacheForTests() expect(pathRipgrepCommand()).toBeNull() @@ -77,7 +80,7 @@ describe('relay PATH ripgrep resolution', () => { // Why a relative PATH entry is skipped: it resolves against the cwd, the hazard being avoided. it('ignores relative PATH entries on Windows', () => { setPlatform('win32') - process.env.PATH = `.${delimiter}node_modules/.bin` + process.env.PATH = '.;node_modules/.bin' resetRelayRipgrepPathCacheForTests() expect(pathRipgrepCommand()).toBeNull() diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index b602d353ea8..e66ff7d20a5 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -16,6 +16,7 @@ import { export type InstallPluginsResult = { installed: { + opencodeStartupPrompt?: boolean opencode: boolean opencode2?: boolean pi: boolean @@ -43,18 +44,21 @@ export function createInstallPluginsHandler( let materialized2: { source: string; sourceDir: string | undefined; dir: string } | null = null return (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource // Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay. + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -135,8 +139,28 @@ export function createInstallPluginsHandler( } } } + const promptConfigDirs = [opencodeDir, opencode2Dir].filter( + (dir): dir is string => typeof dir === 'string' + ) + if (promptConfigDirs.length === 0) { + promptConfigDirs.push( + resolveOpenCodeSourceConfigDir( + Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => + typeof value === 'string' ? [[key, value]] : [] + ) + ), + env.SHELL + ) ?? resolveOpenCodeConfigDirectory(env, env.HOME) + ) + } + const promptInstallResults = promptConfigDirs.map((dir) => + pluginOverlay.installOpenCodeStartupPromptPlugin(env, dir) + ) + const startupPromptInstalled = promptInstallResults.every(Boolean) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: pluginOverlay.hasOpenCodeSource(), opencode2: pluginOverlay.hasOpenCode2Source(), pi: pluginOverlay.hasPiSource('pi'), diff --git a/src/renderer/src/app-shell/app-command-handlers-child-workspaces.test.ts b/src/renderer/src/app-shell/app-command-handlers-child-workspaces.test.ts new file mode 100644 index 00000000000..cfcfea4fb22 --- /dev/null +++ b/src/renderer/src/app-shell/app-command-handlers-child-workspaces.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppShortcutState, ShortcutDispatchInput } from './app-command-handlers' + +const mocks = vi.hoisted(() => { + const target: { groupKey: string | null } = { groupKey: 'lineage:parent' } + return { + target, + floatingFocused: false, + visibleModal: false, + requestScrollAnchor: vi.fn(), + notifyTerminalCapture: vi.fn(), + store: { + activeModal: 'none', + collapsedGroups: new Set(), + setSidebarOpen: vi.fn(), + toggleCollapsedGroup: vi.fn() + } + } +}) + +vi.mock('../store', () => ({ + useAppStore: Object.assign(vi.fn(), { getState: () => mocks.store }) +})) + +vi.mock('../components/sidebar/child-workspaces-toggle-target', () => ({ + getRenderedLineageChipKeys: () => new Set(), + resolveChildWorkspacesToggleGroupKey: () => mocks.target.groupKey +})) + +vi.mock('@/hooks/requestVirtualizedScrollAnchorRecord', () => ({ + requestVirtualizedScrollAnchorRecord: mocks.requestScrollAnchor +})) + +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + isFloatingWorkspacePanelFocused: () => mocks.floatingFocused +})) + +vi.mock('@/lib/visible-overlay', () => ({ + hasVisibleOverlay: () => mocks.visibleModal +})) + +vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({ + showTerminalShortcutCaptureNotification: mocks.notifyTerminalCapture +})) + +import { createAppCommandHandlers } from './app-command-handlers' + +function shortcutState(): AppShortcutState { + return { + activeView: 'terminal', + activeWorktreeId: 'parent', + actions: { + toggleSidebar: vi.fn(), + toggleRightSidebar: vi.fn(), + setRightSidebarOpen: vi.fn(), + setRightSidebarTab: vi.fn(), + showRightSidebarFiles: vi.fn(), + showRightSidebarSearch: vi.fn(), + openDiffNotesSendMenuForActiveWorktree: vi.fn() + }, + creationLayoutActive: false, + floatingTerminalEnabled: false, + floatingTerminalOpen: false, + floatingVisibleTabCount: 0, + keybindings: {}, + openFloatingWorkspaceMaximized: vi.fn(), + pluginCommands: [], + setFloatingTerminalOpen: vi.fn(), + terminalShortcutPolicy: 'orca-first', + workspaceChromeActive: true + } +} + +function shortcutInput(): ShortcutDispatchInput { + return { target: null, defaultPrevented: false, preventDefault: vi.fn() } +} + +function runToggle(input: ShortcutDispatchInput): boolean | undefined { + return createAppCommandHandlers(shortcutState(), input).get('sidebar.childWorkspaces.toggle')?.() +} + +describe('child workspaces toggle app command', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.target.groupKey = 'lineage:parent' + mocks.floatingFocused = false + mocks.visibleModal = false + mocks.store.activeModal = 'none' + mocks.store.collapsedGroups = new Set() + }) + + it('hides the target’s children without forcing the sidebar open', () => { + const input = shortcutInput() + + expect(runToggle(input)).toBe(true) + expect(input.preventDefault).toHaveBeenCalledOnce() + expect(mocks.requestScrollAnchor).toHaveBeenCalledWith('[data-worktree-sidebar]') + expect(mocks.store.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent') + expect(mocks.store.setSidebarOpen).not.toHaveBeenCalled() + expect(mocks.requestScrollAnchor.mock.invocationCallOrder[0]).toBeLessThan( + mocks.store.toggleCollapsedGroup.mock.invocationCallOrder[0] ?? 0 + ) + }) + + it('opens the sidebar when showing hidden children', () => { + mocks.store.collapsedGroups = new Set(['lineage:parent']) + + expect(runToggle(shortcutInput())).toBe(true) + expect(mocks.store.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent') + expect(mocks.store.setSidebarOpen).toHaveBeenCalledWith(true) + }) + + it('lets the chord through when the target is in no lineage', () => { + mocks.target.groupKey = null + const input = shortcutInput() + + expect(runToggle(input)).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled() + expect(mocks.requestScrollAnchor).not.toHaveBeenCalled() + }) + + it('lets a modal keep its keyboard input', () => { + mocks.store.activeModal = 'delete-worktree' + const input = shortcutInput() + + expect(runToggle(input)).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled() + }) + + it('lets the focused floating workspace keep its keyboard input', () => { + mocks.floatingFocused = true + const input = shortcutInput() + + expect(runToggle(input)).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled() + }) + + it('leaves locally controlled modal input and persistence untouched', () => { + mocks.visibleModal = true + const input = shortcutInput() + + expect(runToggle(input)).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.store.toggleCollapsedGroup).not.toHaveBeenCalled() + expect(mocks.requestScrollAnchor).not.toHaveBeenCalled() + expect(mocks.store.setSidebarOpen).not.toHaveBeenCalled() + }) + + it('reports a claimed terminal shortcut through the existing notification policy', () => { + const input = shortcutInput() + expect( + createAppCommandHandlers(shortcutState(), input, 'terminal').get( + 'sidebar.childWorkspaces.toggle' + )?.() + ).toBe(true) + expect(mocks.notifyTerminalCapture).toHaveBeenCalledWith( + expect.objectContaining({ actionId: 'sidebar.childWorkspaces.toggle' }) + ) + }) +}) diff --git a/src/renderer/src/app-shell/app-command-handlers.ts b/src/renderer/src/app-shell/app-command-handlers.ts index 1a7264efb5e..a91819bfe8b 100644 --- a/src/renderer/src/app-shell/app-command-handlers.ts +++ b/src/renderer/src/app-shell/app-command-handlers.ts @@ -1,10 +1,16 @@ import { useShallow } from 'zustand/react/shallow' import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' import { isFloatingWorkspacePanelFocused } from '@/lib/floating-workspace-terminal-actions' +import { hasVisibleOverlay } from '@/lib/visible-overlay' import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-current-workspace-status' +import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls' import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel' +import { + getRenderedLineageChipKeys, + resolveChildWorkspacesToggleGroupKey +} from '../components/sidebar/child-workspaces-toggle-target' import { requestTerminalTabRename } from '../components/tab-bar/terminal-tab-rename-request' import { deleteHoveredWorkspaceImmediately, @@ -178,6 +184,36 @@ export function createAppCommandHandlers( } }) ], + [ + 'sidebar.childWorkspaces.toggle', + () => { + const store = useAppStore.getState() + // Locally controlled dialogs do not set activeModal. + if ( + store.activeModal !== 'none' || + floatingWorkspaceFocused || + hasVisibleOverlay({ ignoreMatches: '[role="listbox"], [role="menu"]' }) + ) { + return false + } + const groupKey = resolveChildWorkspacesToggleGroupKey( + store, + getRenderedLineageChipKeys(store) + ) + if (!groupKey) { + return false + } + return claim('sidebar.childWorkspaces.toggle', () => { + const expanding = store.collapsedGroups.has(groupKey) + // Why: same scroll anchoring as the chip click, so the viewport does not jump. + requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]') + store.toggleCollapsedGroup(groupKey) + if (expanding) { + store.setSidebarOpen(true) + } + }) + } + ], [ 'floatingWorkspace.maximize', () => { diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 6c52abb6518..879fcea5984 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -21,6 +21,7 @@ import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime- import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' +import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice' /** * App-level subscriptions that must outlive any individual surface. Each one is here because @@ -56,4 +57,5 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): useOsc52ClipboardDefaultOnNotice(persistedUIReady) useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() + useCodexSharedSettingsNotice() } diff --git a/src/renderer/src/app-shell/use-global-keybindings.ts b/src/renderer/src/app-shell/use-global-keybindings.ts index 78051a58ed5..a2c3afeef68 100644 --- a/src/renderer/src/app-shell/use-global-keybindings.ts +++ b/src/renderer/src/app-shell/use-global-keybindings.ts @@ -239,6 +239,12 @@ export function useGlobalKeybindings(args: { if (matchShortcut('workspace.delete') && handlers.get('workspace.delete')?.()) { return } + if ( + matchShortcut('sidebar.childWorkspaces.toggle') && + handlers.get('sidebar.childWorkspaces.toggle')?.() + ) { + return + } for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) { if (matchShortcut(actionId) && handlers.get(actionId)?.()) { return diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index 186a285b26d..7fe9890b48d 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -95,6 +95,37 @@ afterEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) }) +describe('UpdateCard staged install recovery', () => { + it.each([undefined, 'local'] as const)( + 'retries a blocked staged install without downloading again (source %s)', + (source) => { + renderWithInitialStatus({ + state: 'error', + version: '1.4.200', + message: + 'Close the other Orca instances (process IDs: 12345) before installing this update.', + retryAction: 'install', + ...(source ? { source } : {}) + }) + + expect(screen.getByText(/process IDs: 12345/)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })) + expect(quitAndInstall).toHaveBeenCalledTimes(1) + expect(download).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: 'Retry Download' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Choose Another Build' })).toBeNull() + } + ) + + it('keeps the download retry for errors from older hosts without an install action', () => { + renderWithInitialStatus({ state: 'error', version: '1.4.200', message: 'Download failed' }) + + fireEvent.click(screen.getByRole('button', { name: 'Retry Download' })) + expect(download).toHaveBeenCalledTimes(1) + expect(quitAndInstall).not.toHaveBeenCalled() + }) +}) + describe('UpdateCard Windows signature failures', () => { it('does not offer the rejected version as a manual publisher-check bypass', () => { const message = diff --git a/src/renderer/src/components/activity/activity-search-query-work-budget.test.tsx b/src/renderer/src/components/activity/activity-search-query-work-budget.test.tsx new file mode 100644 index 00000000000..e10b55728cb --- /dev/null +++ b/src/renderer/src/components/activity/activity-search-query-work-budget.test.tsx @@ -0,0 +1,254 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import * as clipboard from '../../../../shared/clipboard-text' +import { useAppStore } from '@/store' +import { + LEAF_ID, + makeRepo, + makeTabWithIds, + makeWorktree +} from './ActivityPrototypePage-test-fixtures' +import { + activityThreadMatchesSearchQuery, + createActivityThreadSearchMatcher, + getThreadSearchTextComputeCount +} from './activity-thread-grouping' +import { useAgentPaneThreads } from './use-agent-pane-threads' + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + return { useAppStore: createTestStore() } +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function installThreads(count: number, prompt = 'Task PROJECT ☃ progress'): string[] { + vi.spyOn(Date, 'now').mockReturnValue(100_000) + const repo = makeRepo() + const worktree = makeWorktree() + const tabs = Array.from({ length: count }, (_, index) => + makeTabWithIds(`tab-${index}`, worktree.id) + ) + const entries: Record = {} + for (const tab of tabs) { + const paneKey = makePaneKey(tab.id, LEAF_ID) + entries[paneKey] = { + paneKey, + state: 'working', + prompt, + agentType: 'claude', + updatedAt: 100_000, + stateStartedAt: 99_000, + stateHistory: [] + } + } + useAppStore.setState({ + agentStatusByPaneKey: entries, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + acknowledgedAgentsByPaneKey: {}, + activityClearedAtByPaneKey: {}, + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree] }, + folderWorkspaces: [], + detectedWorktreesByRepo: {}, + tabsByWorktree: { [worktree.id]: tabs }, + unifiedTabsByWorktree: {}, + agentsVisibleHostIds: null, + agentsFilterRepoIds: [] + }) + return Object.keys(entries) +} + +function queryWork(query: string) { + const texts = new Set([query, query.trim(), query.trim().toLowerCase()].filter(Boolean)) + const originalBudget = clipboard.isClipboardTextByteLengthOverLimit + const originalTrim = String.prototype.trim + const originalLower = String.prototype.toLowerCase + const counts = { budget: 0, trim: 0, lower: 0 } + const budget = vi + .spyOn(clipboard, 'isClipboardTextByteLengthOverLimit') + .mockImplementation((text, maxBytes) => { + if (texts.has(text)) { + counts.budget += 1 + } + return originalBudget(text, maxBytes) + }) + const trim = vi.spyOn(String.prototype, 'trim').mockImplementation(function (this: string) { + if (texts.has(String(this))) { + counts.trim += 1 + } + return originalTrim.call(this) + }) + const lower = vi.spyOn(String.prototype, 'toLowerCase').mockImplementation(function ( + this: string + ) { + if (texts.has(String(this))) { + counts.lower += 1 + } + return originalLower.call(this) + }) + return { + counts, + restore() { + budget.mockRestore() + trim.mockRestore() + lower.mockRestore() + } + } +} + +function options(query: string): Parameters[0] { + return { query, readFilter: 'all', groupBy: 'none', selectedPaneKey: null, showChildAgents: true } +} + +describe('Activity search query work in the real hook', () => { + it.each([12, 128, 1000])('prepares one query for %i live threads', (count) => { + installThreads(count) + const work = queryWork(' PROJECT ☃ ') + const activity = renderHook(() => useAgentPaneThreads(options(' PROJECT ☃ '))) + work.restore() + expect(activity.result.current.allThreads).toHaveLength(count) + expect(activity.result.current.visibleThreads).toEqual(activity.result.current.allThreads) + for (const [index, thread] of activity.result.current.visibleThreads.entries()) { + expect(thread).toBe(activity.result.current.allThreads[index]) + } + expect(work.counts).toEqual({ budget: 2, trim: 2, lower: 2 }) + }) + + it.each(['', ' \t\n '])('keeps an empty query lazy: %j', (query) => { + installThreads(12) + const before = getThreadSearchTextComputeCount() + const activity = renderHook(() => useAgentPaneThreads(options(query))) + expect(activity.result.current.visibleThreads).toEqual(activity.result.current.allThreads) + expect(getThreadSearchTextComputeCount()).toBe(before) + }) + + it('retains both original and normalized UTF-8 byte limits', () => { + const query = 'İ'.repeat(700) + installThreads(12, query) + const before = getThreadSearchTextComputeCount() + const work = queryWork(query) + const activity = renderHook(() => useAgentPaneThreads(options(query))) + work.restore() + expect(activity.result.current.allThreads).toHaveLength(12) + expect(activity.result.current.visibleThreads).toEqual([]) + expect(getThreadSearchTextComputeCount()).toBe(before) + expect(work.counts.budget).toBe(2) + }) + + it('does not prepare a row query before read/child/scope filters admit a row', () => { + const panes = installThreads(12) + useAppStore.setState({ + acknowledgedAgentsByPaneKey: Object.fromEntries(panes.map((paneKey) => [paneKey, 100_000])) + }) + const work = queryWork(' PROJECT ☃ ') + const activity = renderHook(() => + useAgentPaneThreads({ ...options(' PROJECT ☃ '), readFilter: 'unread' }) + ) + work.restore() + expect(activity.result.current.allThreads).toHaveLength(12) + expect(activity.result.current.visibleThreads).toEqual([]) + expect(work.counts).toEqual({ budget: 1, trim: 1, lower: 1 }) + }) + + it('keeps selection, child classification and scoped row order', () => { + const panes = installThreads(8) + const parent = panes[0] + const child = panes[1] + useAppStore.setState({ + runtimeAgentOrchestrationByPaneKey: { + [child]: { parentPaneKey: parent, taskId: 'task', dispatchId: 'dispatch' } + }, + acknowledgedAgentsByPaneKey: { [child]: 100_000 } + }) + const activity = renderHook((input) => useAgentPaneThreads(input), { + initialProps: { + ...options('PROJECT'), + readFilter: 'unread' as const, + showChildAgents: false, + selectedPaneKey: child + } + }) + expect(activity.result.current.visibleThreads.map((thread) => thread.paneKey)).toEqual(panes) + act(() => useAppStore.setState({ agentsVisibleHostIds: [toSshExecutionHostId('other')] })) + expect(activity.result.current.visibleThreads.map((thread) => thread.paneKey)).toEqual([child]) + activity.rerender({ + ...options('NO MATCH'), + readFilter: 'unread', + showChildAgents: false, + selectedPaneKey: child + }) + expect(activity.result.current.visibleThreads).toEqual([]) + expect(activity.result.current.effectiveSelectedPaneKey).toBe(child) + }) + + it('uses a changed query and new thread data without a retained query cache', () => { + const panes = installThreads(12, 'first prompt') + const activity = renderHook((query) => useAgentPaneThreads(options(query)), { + initialProps: 'first' + }) + expect(activity.result.current.visibleThreads).toHaveLength(12) + activity.rerender('second') + expect(activity.result.current.visibleThreads).toEqual([]) + act(() => { + const entries = useAppStore.getState().agentStatusByPaneKey + useAppStore.setState({ + agentStatusByPaneKey: { + ...entries, + [panes[2]]: { ...entries[panes[2]], prompt: 'second prompt' } + } + }) + }) + expect(activity.result.current.visibleThreads.map((thread) => thread.paneKey)).toEqual([ + panes[2] + ]) + }) + + it('keeps original oversized query rejection ahead of normalization/search', () => { + installThreads(12) + const query = ' '.repeat(2049) + const before = getThreadSearchTextComputeCount() + const work = queryWork(query) + const activity = renderHook(() => useAgentPaneThreads(options(query))) + work.restore() + expect(activity.result.current.visibleThreads).toEqual([]) + expect(getThreadSearchTextComputeCount()).toBe(before) + expect(work.counts).toEqual({ budget: 1, trim: 0, lower: 0 }) + }) +}) + +describe('prepared Activity search preserves the public one-thread matcher', () => { + it('matches original output and lazy text for Unicode, boundary and empty queries', () => { + installThreads(8, 'Unicode İ 👩🏽‍💻 and BILLING') + const activity = renderHook(() => useAgentPaneThreads(options(''))) + const queries = [ + '', + '\t\n ', + 'BILLING', + ' Unicode İ ', + '👩🏽‍💻', + '\ud800', + 'x'.repeat(2048), + 'é'.repeat(1024), + 'é'.repeat(1025), + 'İ'.repeat(700) + ] + for (const searchQuery of queries) { + const matches = createActivityThreadSearchMatcher(searchQuery) + expect(activity.result.current.allThreads.map(matches)).toEqual( + activity.result.current.allThreads.map((thread) => + activityThreadMatchesSearchQuery({ thread, searchQuery }) + ) + ) + } + }) +}) diff --git a/src/renderer/src/components/activity/activity-thread-grouping.ts b/src/renderer/src/components/activity/activity-thread-grouping.ts index 79eef1b3688..17ad0bb447b 100644 --- a/src/renderer/src/components/activity/activity-thread-grouping.ts +++ b/src/renderer/src/components/activity/activity-thread-grouping.ts @@ -134,6 +134,20 @@ export function isActivitySearchQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } +export function createActivityThreadSearchMatcher( + searchQuery: string +): (thread: AgentPaneThread) => boolean { + if (isActivitySearchQueryTooLarge(searchQuery)) { + return () => false + } + const trimmedQuery = searchQuery.trim() + if (!trimmedQuery) { + return () => true + } + const normalizedQuery = trimmedQuery.toLowerCase() + return (thread) => threadSearchText(thread).includes(normalizedQuery) +} + export function activityThreadMatchesSearchQuery({ thread, searchQuery diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index f9575f02a76..ce1f571cdbc 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -15,7 +15,7 @@ import { collectChildAgentPaneKeys } from './activity-thread-child-agent' const EMPTY_PANE_KEYS: ReadonlySet = new Set() import { filterThreadsByActivityScope, resolveActivityScopeRepoIds } from './activity-scope-filter' import { - activityThreadMatchesSearchQuery, + createActivityThreadSearchMatcher, buildActivityThreadGroups, isActivitySearchQueryTooLarge } from './activity-thread-grouping' @@ -212,6 +212,7 @@ export function useAgentPaneThreads(args: { const normalizedQuery = isActivitySearchQueryTooLarge(deferredQuery) ? null : deferredQuery.trim().toLowerCase() + let matchesSearchQuery: ReturnType | undefined return scopeVisibleThreads.filter((thread) => { // Why: keep the just-selected thread visible after auto-mark-read flips it to read, else unread-only mode makes the clicked row vanish from the list. if ( @@ -232,7 +233,8 @@ export function useAgentPaneThreads(args: { if (normalizedQuery === null) { return false } - return activityThreadMatchesSearchQuery({ thread, searchQuery: normalizedQuery }) + matchesSearchQuery ??= createActivityThreadSearchMatcher(normalizedQuery) + return matchesSearchQuery(thread) }) }, [ scopeVisibleThreads, diff --git a/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts b/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts index 6f1d086ecfa..c6826478399 100644 --- a/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts +++ b/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.test.ts @@ -78,6 +78,92 @@ function makeAnnotation(overrides?: Partial): BrowserPage } describe('formatBrowserAnnotationsAsMarkdown', () => { + it('keeps single-URL output byte-for-byte compatible', () => { + expect(formatBrowserAnnotationsAsMarkdown([makeAnnotation()])).toBe( + [ + '## Design Feedback: /pricing', + '', + '**URL:** https://example.com/pricing', + '**Browser tab id:** page-1', + '**Viewport:** 1280x720', + '', + '### 1. button "Start free trial"', + '**Intent:** change', + '**Selector:** `main.pricing > button.primary`', + '**Location:** `main > .pricing > button`', + '**Source:** src/components/PricingCta.tsx:42:8', + '**React:** ', + '**Bounds:** x=400, y=300, 148x44', + '**Classes:** `primary`', + '**Text:** "Start free trial"', + '**Nearby text:**', + '- Pro', + '- $29/month', + '**Nearby elements:**', + '- span "$29/month"', + '**Computed styles:**', + '- display: inline-flex', + '- position: relative', + '- width: 148px', + '- height: 44px', + '- margin: 0px', + '- padding: 12px 24px', + '- color: rgb(255, 255, 255)', + '- background: rgb(99, 102, 241)', + '- border: 0px none', + '- border-radius: 8px', + '- font-family: Geist, sans-serif', + '- font-size: 16px', + '- font-weight: 600', + '- line-height: 20px', + '- text-align: center', + '**Full DOM path:** `html > body > main.pricing > button.primary`', + '**HTML:**', + '````html', + '', + '````', + '**Feedback:** Make this primary action more obvious.' + ].join('\n') + ) + }) + + it('groups interleaved captured URLs in first-seen order with global numbers and captured viewports', () => { + const first = makeAnnotation() + first.payload.page.viewportWidth = 800 + const second = makeAnnotation({ id: 'note-b', comment: 'B feedback' }) + second.payload.page.sanitizedUrl = 'https://example.com/account' + second.payload.page.viewportWidth = 1024 + second.payload.target.sourceFile = 'src/Account.tsx:10:1' + const third = makeAnnotation({ id: 'note-a2', comment: 'A again' }) + third.payload.page.viewportWidth = 1440 + third.payload.target.sourceFile = 'src/PricingChanged.tsx:3:1' + + const markdown = formatBrowserAnnotationsAsMarkdown([first, second, third]) + expect(markdown.match(/^### \d+\./gm)).toEqual(['### 1.', '### 3.', '### 2.']) + const [pricing, account] = markdown.split('## Design Feedback: ').slice(1) + expect(pricing).toContain('/pricing') + expect(pricing).toContain('**URL:** `https://example.com/pricing`') + expect(pricing).toContain('**Viewport:** 800x720') + expect(pricing).toContain('**Viewport:** 1440x720') + expect(pricing).toContain('src/PricingChanged.tsx:3:1') + expect(pricing).not.toContain('B feedback') + expect(account).toContain('/account') + expect(account).toContain('**URL:** `https://example.com/account`') + expect(account).toContain('**Viewport:** 1024x720') + expect(account).toContain('src/Account.tsx:10:1') + expect(account).toContain('**Feedback:** B feedback') + }) + + it('escapes and bounds added mixed-page context with the existing inline formatting', () => { + const first = makeAnnotation() + const second = makeAnnotation({ browserPageId: 'page`id\n## injected' }) + second.payload.page.sanitizedUrl = 'invalid`url\n## injected' + const markdown = formatBrowserAnnotationsAsMarkdown([first, second]) + expect(markdown).toContain('**URL:** ``invalid`url ## injected``') + expect(markdown).toContain('**Browser tab id:** ``page`id ## injected``') + expect(markdown).not.toContain('\n## injected') + }) + it('includes agent-useful selectors, source, react tree, styles, and feedback', () => { const markdown = formatBrowserAnnotationsAsMarkdown([makeAnnotation()]) diff --git a/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.ts b/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.ts index 06f9bc22f38..b58896d6a64 100644 --- a/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.ts +++ b/src/renderer/src/components/browser-pane/annotate/browser-annotation-output.ts @@ -159,70 +159,92 @@ export function formatBrowserAnnotationsAsMarkdown(annotations: BrowserPageAnnot const firstAnnotation = annotations[0] const first = firstAnnotation.payload - const lines: string[] = [ - `## Design Feedback: ${formatPageHeading(first)}`, - '', - `**URL:** ${first.page.sanitizedUrl}`, - `**Browser tab id:** ${firstAnnotation.browserPageId}`, - `**Viewport:** ${first.page.viewportWidth}x${first.page.viewportHeight}`, - '' - ] - + const groups = new Map() annotations.forEach((annotation, index) => { - const { payload } = annotation - const { target } = payload - const rect = target.rectViewport - const styleLines = formatStyles(target.computedStyles) - - lines.push(`### ${index + 1}. ${annotationElementLabel(payload)}`) - lines.push(`**Intent:** ${annotation.intent}`) - lines.push(`**Selector:** ${inlineCode(target.selector)}`) - if (target.elementPath) { - lines.push(`**Location:** ${inlineCode(target.elementPath)}`) - } - if (target.sourceFile) { - lines.push(`**Source:** ${inlineText(target.sourceFile)}`) - } - if (target.reactComponents) { - lines.push(`**React:** ${inlineText(target.reactComponents)}`) - } - lines.push( - `**Bounds:** x=${Math.round(rect.x)}, y=${Math.round(rect.y)}, ${Math.round(rect.width)}x${Math.round(rect.height)}` - ) - if (target.cssClasses) { - lines.push(`**Classes:** ${inlineCode(target.cssClasses)}`) - } - if (target.selectedText) { - lines.push(`**Selected text:** "${inlineText(target.selectedText)}"`) - } else if (target.textSnippet) { - lines.push(`**Text:** "${inlineText(target.textSnippet)}"`) - } - if (payload.nearbyText.length > 0) { - lines.push('**Nearby text:**') - for (const text of payload.nearbyText) { - lines.push(`- ${inlineText(text)}`) - } - } - if (target.nearbyElements?.length) { - lines.push('**Nearby elements:**') - for (const element of target.nearbyElements) { - lines.push(`- ${inlineText(element)}`) - } - } - if (styleLines.length > 0) { - lines.push('**Computed styles:**') - lines.push(...styleLines) - } - if (target.fullPath) { - lines.push(`**Full DOM path:** ${inlineCode(target.fullPath)}`) - } - if (target.htmlSnippet) { - lines.push('**HTML:**') - lines.push(...fence('html', target.htmlSnippet)) - } - lines.push(`**Feedback:** ${inlineText(annotation.comment)}`) - lines.push('') + const url = annotation.payload.page.sanitizedUrl + const entries = groups.get(url) ?? [] + entries.push({ annotation, index }) + groups.set(url, entries) }) + const mixedPages = groups.size > 1 + const lines: string[] = mixedPages + ? ['## Design Feedback across pages', ''] + : [ + `## Design Feedback: ${formatPageHeading(first)}`, + '', + `**URL:** ${first.page.sanitizedUrl}`, + `**Browser tab id:** ${firstAnnotation.browserPageId}`, + `**Viewport:** ${first.page.viewportWidth}x${first.page.viewportHeight}`, + '' + ] + + for (const [url, entries] of groups) { + if (mixedPages) { + lines.push( + `## Design Feedback: ${inlineText(formatPageHeading(entries[0].annotation.payload))}` + ) + lines.push('', `**URL:** ${inlineCode(inlineText(url))}`, '') + } + entries.forEach(({ annotation, index }) => { + const { payload } = annotation + const { target } = payload + const rect = target.rectViewport + const styleLines = formatStyles(target.computedStyles) + + lines.push(`### ${index + 1}. ${annotationElementLabel(payload)}`) + if (mixedPages) { + lines.push(`**Browser tab id:** ${inlineCode(inlineText(annotation.browserPageId))}`) + lines.push(`**Viewport:** ${payload.page.viewportWidth}x${payload.page.viewportHeight}`) + } + lines.push(`**Intent:** ${annotation.intent}`) + lines.push(`**Selector:** ${inlineCode(target.selector)}`) + if (target.elementPath) { + lines.push(`**Location:** ${inlineCode(target.elementPath)}`) + } + if (target.sourceFile) { + lines.push(`**Source:** ${inlineText(target.sourceFile)}`) + } + if (target.reactComponents) { + lines.push(`**React:** ${inlineText(target.reactComponents)}`) + } + lines.push( + `**Bounds:** x=${Math.round(rect.x)}, y=${Math.round(rect.y)}, ${Math.round(rect.width)}x${Math.round(rect.height)}` + ) + if (target.cssClasses) { + lines.push(`**Classes:** ${inlineCode(target.cssClasses)}`) + } + if (target.selectedText) { + lines.push(`**Selected text:** "${inlineText(target.selectedText)}"`) + } else if (target.textSnippet) { + lines.push(`**Text:** "${inlineText(target.textSnippet)}"`) + } + if (payload.nearbyText.length > 0) { + lines.push('**Nearby text:**') + for (const text of payload.nearbyText) { + lines.push(`- ${inlineText(text)}`) + } + } + if (target.nearbyElements?.length) { + lines.push('**Nearby elements:**') + for (const element of target.nearbyElements) { + lines.push(`- ${inlineText(element)}`) + } + } + if (styleLines.length > 0) { + lines.push('**Computed styles:**') + lines.push(...styleLines) + } + if (target.fullPath) { + lines.push(`**Full DOM path:** ${inlineCode(target.fullPath)}`) + } + if (target.htmlSnippet) { + lines.push('**HTML:**') + lines.push(...fence('html', target.htmlSnippet)) + } + lines.push(`**Feedback:** ${inlineText(annotation.comment)}`) + lines.push('') + }) + } return lines.join('\n').trimEnd() } diff --git a/src/renderer/src/components/browser-pane/annotate/browser-annotation-page-url.ts b/src/renderer/src/components/browser-pane/annotate/browser-annotation-page-url.ts new file mode 100644 index 00000000000..d1d53603c63 --- /dev/null +++ b/src/renderer/src/components/browser-pane/annotate/browser-annotation-page-url.ts @@ -0,0 +1,12 @@ +export function browserAnnotationMatchesPageUrl(capturedUrl: string, currentUrl: string): boolean { + try { + const captured = new URL(capturedUrl) + const current = new URL(currentUrl) + // Capture metadata strips query strings and fragments before leaving the guest. + current.search = '' + current.hash = '' + return captured.href === current.href + } catch { + return false + } +} diff --git a/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx b/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx index 86aeeac65ab..200670cd536 100644 --- a/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx +++ b/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx @@ -39,7 +39,8 @@ export function BrowserGuestAnnotateOverlays({ markupPortalContainer, webviewRef, browserOverlayViewport, - worktreeId + worktreeId, + currentUrl }: { markup: MarkupModeController grab: GrabModeHook @@ -50,6 +51,7 @@ export function BrowserGuestAnnotateOverlays({ webviewRef: MutableRefObject browserOverlayViewport: BrowserOverlayViewport worktreeId: string + currentUrl?: string }): React.JSX.Element { const { pendingAnnotationPayload, @@ -110,6 +112,7 @@ export function BrowserGuestAnnotateOverlays({ {browserAnnotations.length > 0 && browserAnnotationTrayOpen ? ( handleUpdateBrowserAnnotation: ReturnType } { @@ -80,6 +80,7 @@ function renderTray(): { { + it('attributes a retained note to its captured page while preserving edit and delete controls', () => { + const { handleDeleteBrowserAnnotation } = renderTray('https://example.com/next') + expect(screen.getByTitle('https://example.com')).toHaveTextContent('https://example.com') + fireEvent.click(screen.getByRole('button', { name: 'Delete annotation 1' })) + expect(handleDeleteBrowserAnnotation).toHaveBeenCalledWith('annotation-1') + }) + + it('keeps current-page and document-preview metadata compact', () => { + renderTray('https://example.com/?query=private#fragment') + expect(screen.queryByTitle('https://example.com')).not.toBeInTheDocument() + cleanup() + renderTray() + expect(screen.queryByTitle('https://example.com')).not.toBeInTheDocument() + }) + it('seeds the textarea with the current comment when entering edit mode', () => { renderTray() diff --git a/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx b/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx index aa847d2f4e2..a22f18b06d5 100644 --- a/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx +++ b/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx @@ -18,9 +18,11 @@ import { import { BROWSER_ANNOTATION_INTENT_OPTIONS } from '../describe-page/browser-annotation-geometry' import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent' import { preventAgentSendTargetOutsideDismiss } from './prevent-agent-send-target-outside-dismiss' +import { browserAnnotationMatchesPageUrl } from './browser-annotation-page-url' export function BrowserPageAnnotationTray({ browserAnnotations, + currentUrl, annotationTraySendOpen, handleAnnotationTraySendOpenChange, worktreeId, @@ -34,6 +36,7 @@ export function BrowserPageAnnotationTray({ handleUpdateBrowserAnnotation }: { browserAnnotations: BrowserPageAnnotation[] + currentUrl?: string annotationTraySendOpen: boolean handleAnnotationTraySendOpenChange: (open: boolean) => void worktreeId: string @@ -269,8 +272,17 @@ export function BrowserPageAnnotationTray({
{annotation.comment}
-
+
{annotation.intent} + {currentUrl !== undefined && + !browserAnnotationMatchesPageUrl( + annotation.payload.page.sanitizedUrl, + currentUrl + ) ? ( + + {annotation.payload.page.sanitizedUrl} + + ) : null}
diff --git a/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.test.ts b/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.test.ts new file mode 100644 index 00000000000..69625c91ab3 --- /dev/null +++ b/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeAnnotation } from '@/store/slices/browser-annotation-test-fixture' +import type { BrowserSetAnnotationViewportBridgeArgs } from '../../../../../shared/browser-annotation-viewport-bridge' +import { syncGuestAnnotationViewportBridge } from './guest-annotation-viewport-bridge' + +afterEach(() => vi.unstubAllGlobals()) + +function createBridge() { + const setAnnotationViewportBridge = vi + .fn<(args: BrowserSetAnnotationViewportBridgeArgs) => Promise>() + .mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { browser: { setAnnotationViewportBridge } } }) + const notes = [makeAnnotation('page-1', 'old'), makeAnnotation('page-1', 'fresh')] + return { setAnnotationViewportBridge, notes } +} + +describe('current-document annotation marker projection', () => { + it('requires both eligible geometry and the captured URL while retaining global tray indices', () => { + const { setAnnotationViewportBridge, notes } = createBridge() + syncGuestAnnotationViewportBridge({ + toolTargetId: 'page-1', + annotations: notes, + currentDocument: { markerIds: ['fresh'], url: 'https://example.com/?private=query#fragment' }, + pendingPayload: null, + surfaceActive: true, + token: 'token' + }) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ + enabled: true, + emitViewport: false, + markers: [expect.objectContaining({ id: 'fresh', index: 1 })] + }) + ) + syncGuestAnnotationViewportBridge({ + toolTargetId: 'page-1', + annotations: notes, + currentDocument: { markerIds: ['fresh'], url: 'https://example.com/next' }, + pendingPayload: null, + surfaceActive: true, + token: 'token' + }) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false, markers: [] }) + ) + }) + + it('does not resurrect old same-URL geometry on dom-ready or return, but permits pending viewport events', () => { + const { setAnnotationViewportBridge, notes } = createBridge() + const args = { + toolTargetId: 'page-1', + annotations: notes, + currentDocument: { markerIds: [], url: 'https://example.com' }, + pendingPayload: null, + surfaceActive: true, + token: 'token' + } + syncGuestAnnotationViewportBridge(args) + syncGuestAnnotationViewportBridge(args) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false, markers: [] }) + ) + syncGuestAnnotationViewportBridge({ ...args, pendingPayload: notes[1].payload }) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: true, emitViewport: true, markers: [] }) + ) + syncGuestAnnotationViewportBridge({ + ...args, + pendingPayload: notes[1].payload, + surfaceActive: false + }) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false }) + ) + }) + + it('preserves the document-preview caller when no browsing projection is supplied', () => { + const { setAnnotationViewportBridge, notes } = createBridge() + syncGuestAnnotationViewportBridge({ + toolTargetId: 'reminted-preview', + annotations: notes, + pendingPayload: null, + surfaceActive: true, + token: 'token' + }) + expect(setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ + browserPageId: 'reminted-preview', + enabled: true, + markers: [ + expect.objectContaining({ id: 'old', index: 0 }), + expect.objectContaining({ id: 'fresh', index: 1 }) + ] + }) + ) + }) + + it('tolerates a destroyed guest without retaining or throwing a rejected bridge call', async () => { + const { setAnnotationViewportBridge, notes } = createBridge() + setAnnotationViewportBridge.mockRejectedValueOnce(new Error('Guest destroyed')) + expect(() => + syncGuestAnnotationViewportBridge({ + toolTargetId: 'page-1', + annotations: notes, + currentDocument: { markerIds: [], url: 'https://example.com' }, + pendingPayload: null, + surfaceActive: true, + token: 'token' + }) + ).not.toThrow() + await Promise.resolve() + }) +}) diff --git a/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts b/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts index 281fc67c77f..6e28c0fa9d1 100644 --- a/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts +++ b/src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts @@ -2,33 +2,42 @@ import type { BrowserGrabPayload, BrowserPageAnnotation } from '../../../../../shared/browser-grab-types' +import { browserAnnotationMatchesPageUrl } from './browser-annotation-page-url' -/** - * Push the current annotation set into the guest, where badges render in-page so they track scroll - * without a message per frame. Shared by every surface that annotates a guest — the payload is - * derived only from the annotations themselves, so the two surfaces cannot disagree about it. - */ +// Guest-rendered badges track scrolling without a renderer message per frame. export function syncGuestAnnotationViewportBridge({ toolTargetId, annotations, + currentDocument, pendingPayload, surfaceActive, token }: { toolTargetId: string annotations: BrowserPageAnnotation[] + currentDocument?: { markerIds: readonly string[]; url: string } pendingPayload: BrowserGrabPayload | null surfaceActive: boolean token: string }): void { // Why: existing badges render in-guest for smooth scroll; only the pending dialog needs viewport messages. - const markers = annotations.map((annotation, index) => ({ - id: annotation.id, - index, - isFixed: annotation.payload.target.isFixed === true, - rectPage: annotation.payload.target.rectPage, - rectViewport: annotation.payload.target.rectViewport - })) + const eligibleIds = currentDocument ? new Set(currentDocument.markerIds) : null + // Keep tray numbering even when earlier notes belong to retired documents. + const markers = annotations.flatMap((annotation, index) => + currentDocument && + (!eligibleIds?.has(annotation.id) || + !browserAnnotationMatchesPageUrl(annotation.payload.page.sanitizedUrl, currentDocument.url)) + ? [] + : [ + { + id: annotation.id, + index, + isFixed: annotation.payload.target.isFixed === true, + rectPage: annotation.payload.target.rectPage, + rectViewport: annotation.payload.target.rectViewport + } + ] + ) void window.api.browser .setAnnotationViewportBridge({ browserPageId: toolTargetId, diff --git a/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.test.tsx b/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.test.tsx new file mode 100644 index 00000000000..6e66608992a --- /dev/null +++ b/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment happy-dom +import { useLayoutEffect } from 'react' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from '@/store/slices/browser-slice-test-harness' +import { makeAnnotation } from '@/store/slices/browser-annotation-test-fixture' +import type { + BrowserGrabResult, + BrowserCaptureSelectionScreenshotResult +} from '../../../../../shared/browser-grab-types' +import { useGrabMode } from './useGrabMode' +import { useBrowserPageGrabAnnotations } from './use-browser-page-grab-annotations' + +const state = vi.hoisted((): { store?: ReturnType } => ({})) +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (value: ReturnType['getState']>) => unknown + ) => { + if (!state.store) { + throw new Error('Missing test store') + } + return state.store(selector) + } +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((accept) => { + resolve = accept + }) + return { promise, resolve } +} + +beforeEach(() => { + state.store = createTestStore() +}) +afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'api') +}) + +function mount(invalidateBeforePendingEffect = false) { + const selection = deferred() + const screenshot = deferred() + const awaitGrabSelection = vi + .fn() + .mockReturnValueOnce(selection.promise) + .mockReturnValue(new Promise(() => {})) + const captureSelectionScreenshot = vi.fn().mockReturnValue(screenshot.promise) + const cancelGrab = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + browser: { + setGrabMode: vi.fn().mockResolvedValue({ ok: true }), + awaitGrabSelection, + captureSelectionScreenshot, + cancelGrab + } + } + }) + const containerRef = { current: null } + const webviewRef = { current: null } + const setBrowserOverlayViewport = vi.fn() + const setBrowserAnnotationTrayOpen = vi.fn() + const hook = renderHook(() => { + const grab = useGrabMode('page-1') + const annotations = useBrowserPageGrabAnnotations({ + browserTabId: 'page-1', + isActive: false, + grab, + containerRef, + webviewRef, + setBrowserOverlayViewport, + setBrowserAnnotationTrayOpen, + browserAnnotationsLength: 0 + }) + const { cancelPendingBrowserCapture } = annotations + useLayoutEffect(() => { + if (invalidateBeforePendingEffect && grab.state === 'confirming') { + cancelPendingBrowserCapture() + } + }, [grab.state, cancelPendingBrowserCapture]) + return { grab, annotations } + }) + return { + ...hook, + selection, + screenshot, + awaitGrabSelection, + captureSelectionScreenshot, + cancelGrab + } +} + +function selected(): BrowserGrabResult { + return { opId: 'fixture-op', kind: 'selected', payload: makeAnnotation('page-1').payload } +} + +describe('capture cancellation on a document boundary', () => { + it('does not restore pending capture when loading cancels between commit and passive effects', async () => { + const h = mount(true) + act(() => h.result.current.annotations.startGrabIntent('annotate')) + await waitFor(() => expect(h.awaitGrabSelection).toHaveBeenCalledOnce()) + await act(async () => { + h.selection.resolve(selected()) + h.screenshot.resolve({ ok: false, reason: 'fixture' }) + await h.screenshot.promise + }) + expect(h.result.current.grab.state).toBe('idle') + expect(h.result.current.annotations.pendingAnnotationPayload).toBeNull() + act(() => h.result.current.annotations.handleAddBrowserAnnotation('Canceled capture', 'fix')) + expect(state.store?.getState().browserAnnotationsByPageId['page-1']).toBeUndefined() + expect(state.store?.getState().browserAnnotationMarkerIdsByPageId['page-1']).toBeUndefined() + }) + + it('ignores a same-page selection that completes after document invalidation', async () => { + const h = mount() + act(() => h.result.current.annotations.startGrabIntent('annotate')) + await waitFor(() => expect(h.awaitGrabSelection).toHaveBeenCalledOnce()) + act(() => h.result.current.annotations.cancelPendingBrowserCapture()) + await act(async () => { + h.selection.resolve(selected()) + await h.selection.promise + }) + expect(h.cancelGrab).toHaveBeenCalledWith({ browserPageId: 'page-1' }) + expect(h.captureSelectionScreenshot).not.toHaveBeenCalled() + expect(h.result.current.grab.state).toBe('idle') + expect(h.result.current.annotations.pendingAnnotationPayload).toBeNull() + expect(state.store?.getState().browserAnnotationsByPageId['page-1']).toBeUndefined() + }) + + it('ignores a same-URL screenshot that finishes after cancellation without creating eligible geometry', async () => { + const h = mount() + act(() => h.result.current.annotations.startGrabIntent('annotate')) + await waitFor(() => expect(h.awaitGrabSelection).toHaveBeenCalledOnce()) + await act(async () => { + h.selection.resolve(selected()) + await h.selection.promise + }) + expect(h.captureSelectionScreenshot).toHaveBeenCalledOnce() + act(() => h.result.current.annotations.cancelPendingBrowserCapture()) + await act(async () => { + h.screenshot.resolve({ ok: false, reason: 'fixture' }) + await h.screenshot.promise + }) + expect(h.result.current.grab.state).toBe('idle') + expect(h.result.current.annotations.pendingAnnotationPayload).toBeNull() + expect(state.store?.getState().browserAnnotationMarkerIdsByPageId['page-1']).toBeUndefined() + }) + + it('blocks an already-rendered Add callback after invalidation and permits a fresh capture', async () => { + const h = mount() + act(() => h.result.current.annotations.startGrabIntent('annotate')) + await waitFor(() => expect(h.awaitGrabSelection).toHaveBeenCalledOnce()) + await act(async () => { + h.selection.resolve(selected()) + h.screenshot.resolve({ ok: false, reason: 'fixture' }) + await h.screenshot.promise + }) + await waitFor(() => + expect(h.result.current.annotations.pendingAnnotationPayload).not.toBeNull() + ) + const oldAdd = h.result.current.annotations.handleAddBrowserAnnotation + act(() => { + h.result.current.annotations.cancelPendingBrowserCapture() + oldAdd('Stale note', 'fix') + }) + expect(state.store?.getState().browserAnnotationsByPageId['page-1']).toBeUndefined() + + const fresh = deferred() + h.awaitGrabSelection.mockReturnValueOnce(fresh.promise) + act(() => h.result.current.annotations.startGrabIntent('annotate')) + await waitFor(() => expect(h.awaitGrabSelection).toHaveBeenCalledTimes(2)) + await act(async () => { + fresh.resolve(selected()) + await fresh.promise + }) + await waitFor(() => + expect(h.result.current.annotations.pendingAnnotationPayload).not.toBeNull() + ) + act(() => h.result.current.annotations.handleAddBrowserAnnotation('Fresh note', 'change')) + const saved = state.store?.getState().browserAnnotationsByPageId['page-1'] + expect(saved).toHaveLength(1) + expect(saved?.[0].comment).toBe('Fresh note') + expect(state.store?.getState().browserAnnotationMarkerIdsByPageId['page-1']).toEqual([ + saved?.[0].id + ]) + }) +}) diff --git a/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.ts b/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.ts index 16225019b31..7852acd1b2d 100644 --- a/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.ts +++ b/src/renderer/src/components/browser-pane/annotate/use-browser-page-grab-annotations.ts @@ -16,16 +16,18 @@ import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' import { formatGrabPayloadAsText } from './GrabConfirmationSheet' -import type { GrabModeHook } from './useGrabMode' import { createBrowserAnnotationId, createBrowserAnnotationPayload, - DEFAULT_BROWSER_ANNOTATION_PRIORITY, - type BrowserOverlayViewport + DEFAULT_BROWSER_ANNOTATION_PRIORITY } from '../describe-page/browser-annotation-geometry' import { useBrowserPageAnnotationViewportTracking } from './use-browser-page-annotation-viewport-tracking' import { runBrowserGrabActionShortcut } from './browser-page-grab-action' -import type { BrowserPageGrabToastState, GrabIntent } from '../describe-page/browser-page-types' +import type { + BrowserPageGrabAnnotationsOptions, + BrowserPageGrabToastState, + GrabIntent +} from '../describe-page/browser-page-types' const copiedGrabToastMessage = (): string => translate( @@ -55,25 +57,7 @@ export function useBrowserPageGrabAnnotations({ setBrowserOverlayViewport, browserAnnotationsLength, setBrowserAnnotationTrayOpen -}: { - /** Scopes the stored annotations. Stable for the life of the surface. */ - browserTabId: string - /** - * The id main resolves to a guest. Defaults to the annotation scope, which is the same string - * for a browser page — a preview re-mints this on recovery, and its annotations must not be - * orphaned when it does. - */ - toolTargetId?: string - isActive: boolean - grab: GrabModeHook - containerRef: MutableRefObject - trackingContainer?: HTMLDivElement | null - trackingScroller?: HTMLDivElement | null - webviewRef: MutableRefObject - setBrowserOverlayViewport: Dispatch> - browserAnnotationsLength: number - setBrowserAnnotationTrayOpen: Dispatch> -}): { +}: BrowserPageGrabAnnotationsOptions): { grabIntent: GrabIntent startGrabIntent: (nextIntent: GrabIntent) => void pendingAnnotationPayload: BrowserGrabPayload | null @@ -87,6 +71,7 @@ export function useBrowserPageGrabAnnotations({ grabMenuActionTakenRef: MutableRefObject handleAddBrowserAnnotation: (comment: string, intent: BrowserAnnotationIntent) => void handleCancelPendingBrowserAnnotation: () => void + cancelPendingBrowserCapture: () => void handleGrabActionShortcut: (key: 'c' | 's') => void } { const mountedRef = useMountedRef() @@ -114,11 +99,7 @@ export function useBrowserPageGrabAnnotations({ const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const addBrowserPageAnnotation = useAppStore((s) => s.addBrowserPageAnnotation) - useEffect(() => { - return () => { - clearTimeout(grabToastTimerRef.current) - } - }, []) + useEffect(() => () => clearTimeout(grabToastTimerRef.current), []) const dismissGrabToast = useCallback(() => { clearTimeout(grabToastTimerRef.current) @@ -165,13 +146,16 @@ export function useBrowserPageGrabAnnotations({ [containerRef, dismissGrabToast, mountedRef, webviewRef] ) - // Why: the same in-guest picker powers two flows — Cmd/Ctrl+C copies, the toolbar action creates a pending annotation. + // The picker supports clipboard copying and independently dismissible annotation drafts. useEffect(() => { if (grab.state !== 'confirming' || !grab.payload) { return } if (grabIntent === 'annotate') { - setPendingAnnotationPayload(grab.payload) + // Loading may cancel the selection before React accepts this pending snapshot. + setPendingAnnotationPayload(() => + grabPayloadRef.current === grab.payload ? grab.payload : null + ) return } if (!grab.contextMenu) { @@ -267,7 +251,7 @@ export function useBrowserPageGrabAnnotations({ const handleAddBrowserAnnotation = useCallback( (comment: string, intent: BrowserAnnotationIntent): void => { - const payload = pendingAnnotationPayload + const payload = pendingAnnotationPayloadRef.current if (!payload) { return } @@ -281,6 +265,7 @@ export function useBrowserPageGrabAnnotations({ payload: createBrowserAnnotationPayload(payload) }) recordFeatureInteraction('browser-annotations') + pendingAnnotationPayloadRef.current = null setPendingAnnotationPayload(null) setBrowserAnnotationTrayOpen(true) showGrabToast(annotationAddedGrabToastMessage(), 'success', payload) @@ -290,7 +275,6 @@ export function useBrowserPageGrabAnnotations({ addBrowserPageAnnotation, browserTabId, grab, - pendingAnnotationPayload, recordFeatureInteraction, setBrowserAnnotationTrayOpen, showGrabToast @@ -298,12 +282,22 @@ export function useBrowserPageGrabAnnotations({ ) const handleCancelPendingBrowserAnnotation = useCallback((): void => { + pendingAnnotationPayloadRef.current = null setPendingAnnotationPayload(null) if (grabIntent === 'annotate' && grab.state === 'confirming') { grab.rearm() } }, [grab, grabIntent]) + const cancelPendingBrowserCapture = useCallback((): void => { + grabRef.current.cancel() + pendingAnnotationPayloadRef.current = null + grabPayloadRef.current = null + setPendingAnnotationPayload(null) + clearTimeout(grabToastTimerRef.current) + setGrabToast(null) + }, []) + return { grabIntent, startGrabIntent, @@ -318,6 +312,7 @@ export function useBrowserPageGrabAnnotations({ grabMenuActionTakenRef, handleAddBrowserAnnotation, handleCancelPendingBrowserAnnotation, + cancelPendingBrowserCapture, handleGrabActionShortcut } } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx index 33dbfe52c87..eb72a5f0f45 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx @@ -224,7 +224,7 @@ export function BrowserPagePane({ onUpdatePageState, onSetUrl, setAddressBarValue: nav.setAddressBarValue, - setPendingAnnotationPayload: grabAnnotations.setPendingAnnotationPayload, + cancelPendingBrowserCapture: grabAnnotations.cancelPendingBrowserCapture, setBrowserOverlayViewport, setFindOpen, focusAddressBarNow, @@ -232,7 +232,6 @@ export function BrowserPagePane({ paneZoomLevelRef: zoom.paneZoomLevelRef, setBrowserZoomPercent: zoom.setBrowserZoomPercent, pendingAnnotationPayload: grabAnnotations.pendingAnnotationPayload, - browserAnnotationsLength: annotationSend.browserAnnotations.length, inputLocked, faviconUrl: browserTab.faviconUrl, webviewRef, diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx index 6c216a1dd09..3a9403f35fd 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx @@ -84,6 +84,7 @@ export function BrowserPageViewportOverlays({ webviewRef={webviewRef} browserOverlayViewport={browserOverlayViewport} worktreeId={worktreeId} + currentUrl={browserTab.url} /> setFindOpen(false)} webviewRef={webviewRef} /> diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts b/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts index bbe8949d9b2..4a378fe1701 100644 --- a/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts +++ b/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts @@ -1,5 +1,8 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' +import type { GrabModeHook } from '../annotate/useGrabMode' +import type { BrowserOverlayViewport } from './browser-annotation-geometry' export type BrowserTabPageState = Partial< Pick< @@ -35,6 +38,26 @@ export type BrowserPageGrabToastState = { payload: BrowserGrabPayload | null } +export type BrowserPageGrabAnnotationsOptions = { + /** Scopes the stored annotations. Stable for the life of the surface. */ + browserTabId: string + /** + * The id main resolves to a guest. Defaults to the annotation scope, which is the same string + * for a browser page — a preview re-mints this on recovery, and its annotations must not be + * orphaned when it does. + */ + toolTargetId?: string + isActive: boolean + grab: GrabModeHook + containerRef: MutableRefObject + trackingContainer?: HTMLDivElement | null + trackingScroller?: HTMLDivElement | null + webviewRef: MutableRefObject + setBrowserOverlayViewport: Dispatch> + browserAnnotationsLength: number + setBrowserAnnotationTrayOpen: Dispatch> +} + export type BrowserPageRecoveryNavigationValidation = { committed: boolean started: boolean diff --git a/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts b/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts index 4d690b31f03..dd880308b26 100644 --- a/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts @@ -1,5 +1,4 @@ import type { Dispatch, DragEvent, MutableRefObject, RefObject, SetStateAction } from 'react' -import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' import { normalizeBrowserNavigationUrl, redactKagiSessionToken @@ -52,9 +51,8 @@ export type AttachBrowserPageWebviewArgs = { addressBarInputRef: RefObject lastKnownWebviewUrlRef: MutableRefObject trackNextLoadingEventRef: MutableRefObject - clearBrowserPageAnnotationsRef: MutableRefObject<(pageId: string) => void> + invalidateBrowserAnnotationDocumentRef: MutableRefObject<() => void> onSetUrlRef: MutableRefObject - setPendingAnnotationPayload: Dispatch> setBrowserOverlayViewport: Dispatch> setAddressBarValue: Dispatch> addBrowserHistoryEntryRef: MutableRefObject< @@ -108,6 +106,7 @@ export function attachBrowserPageWebview( if (!container || !webviewContainer) { return } + args.invalidateBrowserAnnotationDocumentRef.current() const webview = ensuredWebview.webview const needsInitialNavigation = ensuredWebview.created seedLiveBrowserUrl(browserTabId, redactKagiSessionToken(browserTabUrlRef.current)) diff --git a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts index f8699324889..a7d786e12d3 100644 --- a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts +++ b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts @@ -54,9 +54,8 @@ export function bindBrowserPageWebviewListeners({ addressBarInputRef, lastKnownWebviewUrlRef, trackNextLoadingEventRef, - clearBrowserPageAnnotationsRef, + invalidateBrowserAnnotationDocumentRef, onSetUrlRef, - setPendingAnnotationPayload, setBrowserOverlayViewport, setAddressBarValue, addBrowserHistoryEntryRef, @@ -105,10 +104,9 @@ export function bindBrowserPageWebviewListeners({ trackNextLoadingEventRef, keepAddressBarFocusRef, recoveryNavigationValidationRef, - clearBrowserPageAnnotationsRef, + invalidateBrowserAnnotationDocumentRef, onUpdatePageStateRef, onSetUrlRef, - setPendingAnnotationPayload, setBrowserOverlayViewport, setAddressBarValue, focusAddressBarNow @@ -126,6 +124,7 @@ export function bindBrowserPageWebviewListeners({ webview, browserTabId, browserTabUrl, + invalidateBrowserAnnotationDocumentRef, recoveryNavigationValidationRef, activeLoadFailureRef, lastKnownWebviewUrlRef, diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts new file mode 100644 index 00000000000..2ff2ebbab08 --- /dev/null +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createBrowserMockApi, createTestStore } from '@/store/slices/browser-slice-test-harness' +import { makeAnnotation } from '@/store/slices/browser-annotation-test-fixture' +import { syncGuestAnnotationViewportBridge } from '../annotate/guest-annotation-viewport-bridge' +import { createBrowserPageWebviewLoadingHandlers } from './browser-page-webview-loading-handlers' +import { createBrowserPageWebviewNavigationHandlers } from './browser-page-webview-navigation-handlers' + +afterEach(() => vi.unstubAllGlobals()) + +function createHarness() { + const setAnnotationViewportBridge = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + ...createBrowserMockApi(vi.fn()), + browser: { ...createBrowserMockApi(vi.fn()).browser, setAnnotationViewportBridge } + } + }) + const store = createTestStore() + const pageId = store.getState().createBrowserTab('wt-1', 'https://example.com').activePageId + if (!pageId) { + throw new Error('Expected browser page') + } + store.getState().addBrowserPageAnnotation(makeAnnotation(pageId)) + const ref = (current: T) => ({ current }) + const cancelCapture = vi.fn() + const setBrowserOverlayViewport = vi.fn() + const url = ref('https://example.com/') + const webviewFixture = { + getURL: () => url.current ?? '', + getTitle: () => 'Example', + canGoBack: () => false, + canGoForward: () => false, + src: 'https://example.com/' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these factories only use the guest methods explicitly supplied by this fixture. + const webview = webviewFixture as unknown as Electron.WebviewTag + const invalidateBrowserAnnotationDocumentRef = ref(() => { + store.getState().invalidateBrowserPageAnnotationGeometry(pageId) + cancelCapture() + syncGuestAnnotationViewportBridge({ + toolTargetId: pageId, + annotations: store.getState().browserAnnotationsByPageId[pageId], + currentDocument: { + markerIds: store.getState().browserAnnotationMarkerIdsByPageId[pageId] ?? [], + url: url.current ?? '' + }, + pendingPayload: null, + surfaceActive: true, + token: 'token' + }) + }) + const common = { + webview, + browserTabId: pageId, + faviconUrlRef: ref(null), + addressBarInputRef: ref(null), + activeLoadFailureRef: ref(null), + lastKnownWebviewUrlRef: url, + recoveryNavigationValidationRef: ref(null), + invalidateBrowserAnnotationDocumentRef, + onUpdatePageStateRef: ref(vi.fn()), + onSetUrlRef: ref((id: string, nextUrl: string) => + store.getState().setBrowserPageUrl(id, nextUrl) + ), + setBrowserOverlayViewport, + setAddressBarValue: vi.fn() + } + const loading = createBrowserPageWebviewLoadingHandlers({ + ...common, + browserTabUrlRef: ref('https://example.com/'), + addressBarValueRef: ref('https://example.com/'), + trackNextLoadingEventRef: ref(false), + keepAddressBarFocusRef: ref(false), + focusAddressBarNow: () => false + }) + const navigation = createBrowserPageWebviewNavigationHandlers({ + ...common, + browserTabUrl: 'https://example.com/', + addBrowserHistoryEntryRef: ref(vi.fn()), + annotationViewportBridgeTokenRef: ref('token') + }) + return { + store, + pageId, + loading, + navigation, + cancelCapture, + setAnnotationViewportBridge, + setBrowserOverlayViewport + } +} + +function navigationEvent( + url: string, + isInPlace = false, + isMainFrame = true +): Electron.DidStartNavigationEvent { + return Object.assign(new Event('did-start-navigation'), { + url, + isInPlace, + isMainFrame, + frameProcessId: 1, + frameRoutingId: 1 + }) +} + +describe('browser annotation document boundaries', () => { + it('retains the exact saved notes on actual same-URL loading-start, including untracked loads', () => { + const h = createHarness() + const saved = h.store.getState().browserAnnotationsByPageId[h.pageId] + h.loading.handleDidStartLoading() + expect(h.store.getState().browserAnnotationsByPageId[h.pageId]).toBe(saved) + expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined() + expect(h.cancelCapture).toHaveBeenCalledOnce() + expect(h.setBrowserOverlayViewport).toHaveBeenCalledWith({ scrollX: 0, scrollY: 0, version: 0 }) + expect(h.setAnnotationViewportBridge).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false, emitViewport: false, markers: [] }) + ) + }) + + it('retires geometry for full and same-document main-frame navigation but not subframes', () => { + const h = createHarness() + h.navigation.handleDidStartNavigation( + navigationEvent('https://example.com/frame', false, false) + ) + expect(h.cancelCapture).not.toHaveBeenCalled() + h.navigation.handleDidStartNavigation(navigationEvent('https://example.com/#new', true)) + expect(h.cancelCapture).toHaveBeenCalledOnce() + h.store.getState().addBrowserPageAnnotation(makeAnnotation(h.pageId, 'fresh')) + h.navigation.handleDidStartNavigation(navigationEvent('https://example.com/next')) + expect(h.cancelCapture).toHaveBeenCalledTimes(2) + expect(h.store.getState().browserAnnotationsByPageId[h.pageId]).toHaveLength(2) + expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined() + }) + + it('keeps geometry retired after redirects and away/back commits, including missed start events', () => { + const h = createHarness() + const saved = h.store.getState().browserAnnotationsByPageId[h.pageId] + h.loading.handleDidStartLoading() + h.navigation.handleDidRedirectNavigation(navigationEvent('https://example.org/redirect')) + h.navigation.handleFullDidNavigate({ url: 'https://example.org/redirect', isMainFrame: true }) + h.navigation.handleFullDidNavigate({ url: 'https://example.com/', isMainFrame: true }) + expect(h.store.getState().browserAnnotationsByPageId[h.pageId]).toBe(saved) + expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined() + h.store.getState().addBrowserPageAnnotation(makeAnnotation(h.pageId, 'fresh')) + h.navigation.handleDidNavigateInPage({ url: 'https://example.com/#another', isMainFrame: true }) + expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts index 924e7834319..40b0e122a1f 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-retention.test.ts @@ -27,6 +27,7 @@ function createHarness(startUrl: string) { webview, browserTabId: TAB_ID, browserTabUrl: startUrl, + invalidateBrowserAnnotationDocumentRef: ref(vi.fn()), recoveryNavigationValidationRef: ref(null), activeLoadFailureRef: ref(null), // Why the destination, not the current document: Orca-driven navigations set this ref before @@ -53,10 +54,9 @@ function createHarness(startUrl: string) { trackNextLoadingEventRef: ref(true), keepAddressBarFocusRef: ref(false), recoveryNavigationValidationRef: ref(null), - clearBrowserPageAnnotationsRef: ref(vi.fn()), + invalidateBrowserAnnotationDocumentRef: ref(vi.fn()), onUpdatePageStateRef, onSetUrlRef: ref(vi.fn()), - setPendingAnnotationPayload: vi.fn(), setBrowserOverlayViewport: vi.fn(), setAddressBarValue: vi.fn(), focusAddressBarNow: () => false diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts index b6887f119eb..31082559244 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts @@ -1,5 +1,4 @@ import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from 'react' -import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-types' import { normalizeBrowserNavigationUrl, redactKagiSessionToken @@ -35,10 +34,9 @@ export type BrowserPageWebviewLoadingHandlersArgs = { trackNextLoadingEventRef: MutableRefObject keepAddressBarFocusRef: MutableRefObject recoveryNavigationValidationRef: MutableRefObject - clearBrowserPageAnnotationsRef: MutableRefObject<(pageId: string) => void> + invalidateBrowserAnnotationDocumentRef: MutableRefObject<() => void> onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> onSetUrlRef: MutableRefObject - setPendingAnnotationPayload: Dispatch> setBrowserOverlayViewport: Dispatch> setAddressBarValue: Dispatch> focusAddressBarNow: () => boolean @@ -62,18 +60,16 @@ export function createBrowserPageWebviewLoadingHandlers({ trackNextLoadingEventRef, keepAddressBarFocusRef, recoveryNavigationValidationRef, - clearBrowserPageAnnotationsRef, + invalidateBrowserAnnotationDocumentRef, onUpdatePageStateRef, onSetUrlRef, - setPendingAnnotationPayload, setBrowserOverlayViewport, setAddressBarValue, focusAddressBarNow }: BrowserPageWebviewLoadingHandlersArgs): BrowserPageWebviewLoadingHandlers { const handleDidStartLoading = (): void => { - // Why: a reload replaces the document without changing the URL, invalidating captured element rects like a navigation does. - clearBrowserPageAnnotationsRef.current(browserTabId) - setPendingAnnotationPayload(null) + // A reload replaces geometry even when the URL stays the same. + invalidateBrowserAnnotationDocumentRef.current() setBrowserOverlayViewport({ scrollX: 0, scrollY: 0, version: 0 }) if (!trackNextLoadingEventRef.current) { return diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts index 240e763cf63..1c08005a6fc 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts @@ -28,6 +28,7 @@ export type BrowserPageWebviewNavigationHandlersArgs = { webview: Electron.WebviewTag browserTabId: string browserTabUrl: string + invalidateBrowserAnnotationDocumentRef: MutableRefObject<() => void> recoveryNavigationValidationRef: MutableRefObject activeLoadFailureRef: MutableRefObject lastKnownWebviewUrlRef: MutableRefObject @@ -57,6 +58,7 @@ export function createBrowserPageWebviewNavigationHandlers({ webview, browserTabId, browserTabUrl, + invalidateBrowserAnnotationDocumentRef, recoveryNavigationValidationRef, activeLoadFailureRef, lastKnownWebviewUrlRef, @@ -92,6 +94,9 @@ export function createBrowserPageWebviewNavigationHandlers({ } const handleDidStartNavigation = (event: Electron.DidStartNavigationEvent): void => { + if (event.isMainFrame && event.url) { + invalidateBrowserAnnotationDocumentRef.current() + } if (!event.isMainFrame || event.isInPlace || !event.url) { return } @@ -126,6 +131,9 @@ export function createBrowserPageWebviewNavigationHandlers({ const browserModelUrl = redactKagiSessionToken(currentUrl) const normalizedBrowserModelUrl = normalizeBrowserNavigationUrl(browserModelUrl) ?? browserModelUrl + if (lastKnownWebviewUrlRef.current !== normalizedBrowserModelUrl) { + invalidateBrowserAnnotationDocumentRef.current() + } lastKnownWebviewUrlRef.current = normalizedBrowserModelUrl rememberLiveBrowserUrl(browserTabId, browserModelUrl) // Why: don't overwrite in-progress typing (see above). diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts index 72859a415b5..5e5a8036f80 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts @@ -54,7 +54,7 @@ export function useBrowserPageWebviewLifecycle({ onUpdatePageState, onSetUrl, setAddressBarValue, - setPendingAnnotationPayload, + cancelPendingBrowserCapture, setBrowserOverlayViewport, setFindOpen, focusAddressBarNow, @@ -62,7 +62,6 @@ export function useBrowserPageWebviewLifecycle({ paneZoomLevelRef, setBrowserZoomPercent, pendingAnnotationPayload, - browserAnnotationsLength, inputLocked, faviconUrl, webviewRef, @@ -96,7 +95,7 @@ export function useBrowserPageWebviewLifecycle({ onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void onSetUrl: BrowserPageUrlSetter setAddressBarValue: Dispatch> - setPendingAnnotationPayload: Dispatch> + cancelPendingBrowserCapture: () => void setBrowserOverlayViewport: Dispatch> setFindOpen: Dispatch> focusAddressBarNow: () => boolean @@ -104,7 +103,6 @@ export function useBrowserPageWebviewLifecycle({ paneZoomLevelRef: MutableRefObject setBrowserZoomPercent: Dispatch> pendingAnnotationPayload: BrowserGrabPayload | null - browserAnnotationsLength: number inputLocked: boolean faviconUrl: string | null webviewRef: MutableRefObject @@ -139,9 +137,10 @@ export function useBrowserPageWebviewLifecycle({ const browserAnnotations = useAppStore( (s) => s.browserAnnotationsByPageId[browserTabId] ?? EMPTY_BROWSER_ANNOTATIONS ) - const browserAnnotationsRef = useRef(browserAnnotations) - const clearBrowserPageAnnotations = useAppStore((s) => s.clearBrowserPageAnnotations) - const clearBrowserPageAnnotationsRef = useRef(clearBrowserPageAnnotations) + const browserAnnotationMarkerIds = useAppStore( + (s) => s.browserAnnotationMarkerIdsByPageId[browserTabId] + ) + const invalidatePendingCaptureRef = useRef(cancelPendingBrowserCapture) useLayoutEffect(() => { browserTabLoadingRef.current = browserTabLoading @@ -149,13 +148,11 @@ export function useBrowserPageWebviewLifecycle({ viewportPresetIdRef.current = viewportPresetId isActiveRef.current = isActive pendingAnnotationPayloadRef.current = pendingAnnotationPayload - browserAnnotationsRef.current = browserAnnotations - clearBrowserPageAnnotationsRef.current = clearBrowserPageAnnotations + invalidatePendingCaptureRef.current = cancelPendingBrowserCapture isPaintableRef.current = isPaintable }, [ - browserAnnotations, browserTabLoading, - clearBrowserPageAnnotations, + cancelPendingBrowserCapture, inputLocked, isActive, isPaintable, @@ -211,14 +208,30 @@ export function useBrowserPageWebviewLifecycle({ ) const syncBrowserAnnotationViewportBridge = useCallback((): void => { + const state = useAppStore.getState() syncGuestAnnotationViewportBridge({ toolTargetId: browserTabId, - annotations: browserAnnotationsRef.current, + annotations: state.browserAnnotationsByPageId[browserTabId] ?? EMPTY_BROWSER_ANNOTATIONS, + currentDocument: { + markerIds: state.browserAnnotationMarkerIdsByPageId[browserTabId] ?? [], + url: browserTabUrlRef.current + }, pendingPayload: pendingAnnotationPayloadRef.current, surfaceActive: isActiveRef.current, token: annotationViewportBridgeTokenRef.current }) - }, [browserTabId]) + }, [browserTabId, browserTabUrlRef]) + + const invalidateBrowserAnnotationDocument = useCallback((): void => { + useAppStore.getState().invalidateBrowserPageAnnotationGeometry(browserTabId) + invalidatePendingCaptureRef.current() + pendingAnnotationPayloadRef.current = null + syncBrowserAnnotationViewportBridge() + }, [browserTabId, syncBrowserAnnotationViewportBridge]) + const invalidateBrowserAnnotationDocumentRef = useRef(invalidateBrowserAnnotationDocument) + useLayoutEffect(() => { + invalidateBrowserAnnotationDocumentRef.current = invalidateBrowserAnnotationDocument + }, [invalidateBrowserAnnotationDocument]) // Why: browserTab.url excluded from deps (changes every navigation → would destroy/recreate the webview); URL logic reads browserTabUrlRef. useEffect(() => { @@ -255,9 +268,8 @@ export function useBrowserPageWebviewLifecycle({ addressBarInputRef, lastKnownWebviewUrlRef, trackNextLoadingEventRef, - clearBrowserPageAnnotationsRef, + invalidateBrowserAnnotationDocumentRef, onSetUrlRef, - setPendingAnnotationPayload, setBrowserOverlayViewport, setAddressBarValue, addBrowserHistoryEntryRef, @@ -295,8 +307,10 @@ export function useBrowserPageWebviewLifecycle({ useEffect(() => { syncBrowserAnnotationViewportBridge() }, [ - browserAnnotationsLength, + browserAnnotations, + browserAnnotationMarkerIds, browserTabId, + browserTabUrl, isActive, pendingAnnotationPayload, syncBrowserAnnotationViewportBridge diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx new file mode 100644 index 00000000000..3f07b0b0553 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.frame-lifecycle.test.tsx @@ -0,0 +1,268 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ITerminalOptions } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { useAppStore } from '@/store' +import { AgentTerminalPreview } from './AgentTerminalPreview' + +type PreviewInstance = { + options: ITerminalOptions + container: HTMLElement | null + dispose: ReturnType + writeCallbacks: (() => void)[] +} +const harness = vi.hoisted(() => ({ + instances: new Array(), + deferWrites: false +})) + +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + cols = 80 + rows = 24 + buffer = { active: { cursorY: 14 } } + container: HTMLElement | null = null + screen = document.createElement('div') + writeCallbacks: (() => void)[] = [] + write = vi.fn((_data: string, callback?: () => void) => { + if (!callback) { + return + } + if (harness.deferWrites) { + this.writeCallbacks.push(callback) + } else { + callback() + } + }) + focus = vi.fn() + resize = vi.fn() + reset = vi.fn() + onData = vi.fn(() => ({ dispose: vi.fn() })) + dispose = vi.fn(() => this.screen.remove()) + constructor(public options: ITerminalOptions) { + harness.instances.push(this) + } + open(container: HTMLElement): void { + this.container = container + this.screen.className = 'xterm-screen' + Object.defineProperties(this.screen, { + offsetWidth: { + get: () => + this.cols * + (this.options.fontSize === 18 || + this.options.fontFamily?.includes('Fira Code') || + this.options.fontWeight === 900 || + this.options.fontWeightBold === 900 + ? 12 + : 10) + }, + offsetHeight: { get: () => this.rows * 16 * (this.options.lineHeight ?? 1) } + }) + const box = container.parentElement + if (!box) { + throw new Error('Missing preview box') + } + Object.defineProperties(box, { + clientWidth: { configurable: true, value: 600 }, + clientHeight: { configurable: true, value: 240 } + }) + container.append(this.screen) + } + } +})) +vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({ + subscribeToTerminalUserInput: () => ({ dispose: vi.fn() }) +})) +vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({ + useSystemPrefersDark: () => false +})) +vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({ + useEffectiveMacOptionAsAlt: (value: string) => value +})) +vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() })) +vi.mock('./preview-terminal-compatibility', () => ({ + installPreviewTerminalCompatibility: () => vi.fn() +})) +vi.mock('./preview-terminal-ime-bridge', () => ({ + installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: vi.fn() }) +})) +vi.mock('./preview-terminal-key-handler', () => ({ + installPreviewTerminalKeyHandler: () => vi.fn() +})) +vi.mock('@/components/terminal-pane/terminal-native-copy-gutter', () => ({ + installTerminalNativeCopyGutterTrim: () => ({ dispose: vi.fn() }) +})) +vi.mock('./preview-terminal-app-menu-clipboard', () => ({ + installPreviewTerminalAppMenuClipboard: () => vi.fn() +})) +vi.mock('./preview-terminal-right-click-paste', () => ({ + installPreviewTerminalRightClickPaste: () => vi.fn() +})) + +const initial = useAppStore.getInitialState() +const connect = vi.fn() +const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows })) +const unsubscribe = vi.fn(async () => {}) +let settings: GlobalSettings +let originalApi: PropertyDescriptor | undefined +const frames = new Map() +let nextFrame = 0 +const cancelFrame = vi.fn((id: number) => frames.delete(id)) +function flushFrames(): void { + for (const [id, callback] of frames) { + frames.delete(id) + callback(16) + } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + harness.instances.length = 0 + harness.deferWrites = false + frames.clear() + nextFrame = 0 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextFrame++ + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', cancelFrame) + // No resize notification or later output: replay must perform its own fit and grid claim. + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn() + disconnect = vi.fn() + } + ) + settings = createGlobalSettingsFixture({ + theme: 'dark', + activeRuntimeEnvironmentId: null, + terminalFontSize: 14, + terminalFontFamily: 'JetBrains Mono', + terminalFontWeight: 500, + terminalFontWeightBold: 700, + terminalLineHeight: 1, + terminalLigatures: 'off' + }) + connect + .mockReset() + .mockResolvedValue({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] }) + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + settings: { + set: async (updates: Partial) => { + settings = structuredClone({ ...settings, ...updates }) + return settings + } + }, + terminalPreview: { connect, fit, unsubscribe, onData: () => vi.fn() } + } + }) + useAppStore.setState({ ...initial, settings }, true) +}) + +afterEach(() => { + cleanup() + for (const instance of harness.instances) { + expect(instance.dispose).toHaveBeenCalledOnce() + } + useAppStore.setState(initial, true) + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } + frames.clear() + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +it('releases every frame after 64 actual preview unmounts', async () => { + for (let index = 0; index < 64; index++) { + const view = render() + await act(async () => {}) + view.unmount() + } + expect(connect).toHaveBeenCalledTimes(64) + expect(unsubscribe).toHaveBeenCalledTimes(64) + expect(harness.instances).toHaveLength(64) + for (const instance of harness.instances) { + expect(instance.dispose).toHaveBeenCalledOnce() + } + expect(fit).not.toHaveBeenCalled() + expect(frames.size).toBe(0) + expect(cancelFrame).toHaveBeenCalledTimes(64) + expect(cancelFrame).toHaveBeenCalledWith(0) +}) + +it('does not schedule an old parsed write after actual unmount', async () => { + harness.deferWrites = true + const view = render() + await act(async () => {}) + const terminal = harness.instances[0] + expect(terminal).toBeDefined() + flushFrames() + view.unmount() + for (const callback of terminal?.writeCallbacks.splice(0) ?? []) { + callback() + } + expect(connect).toHaveBeenCalledOnce() + expect(unsubscribe).toHaveBeenCalledOnce() + expect(terminal?.dispose).toHaveBeenCalledOnce() + expect(frames.size).toBe(0) +}) + +it('retains the replacement frame on the same DOM and ignores retired parsed writes', async () => { + harness.deferWrites = true + render() + await act(async () => {}) + const old = harness.instances[0] + await act(async () => useAppStore.getState().updateSettings({ terminalFontSize: 18 })) + const replacement = harness.instances[1] + const pending = frames.size + for (const callback of old?.writeCallbacks.splice(0) ?? []) { + callback() + } + const afterOldWrites = frames.size + flushFrames() + await act(async () => { + await vi.advanceTimersByTimeAsync(200) + }) + expect(connect).toHaveBeenCalledTimes(2) + expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(old?.dispose).toHaveBeenCalledOnce() + expect(replacement?.container).toBe(old?.container) + expect(replacement?.container?.style.transform).toBe('scale(0.625)') + expect(replacement?.container?.style.transformOrigin).toBe('top left') + expect(fit).toHaveBeenCalledExactlyOnceWith('pty-1', 50, 15) + expect(frames.size).toBe(0) + expect([pending, afterOldWrites]).toEqual([1, 1]) +}) + +it('keeps live replay writes coalesced and fits the latest cursor and dimensions', async () => { + harness.deferWrites = true + render() + await act(async () => {}) + const terminal = harness.instances[0] + for (const callback of terminal?.writeCallbacks.splice(0) ?? []) { + callback() + } + expect(frames.size).toBe(1) + flushFrames() + await act(async () => { + await vi.advanceTimersByTimeAsync(200) + }) + expect(connect).toHaveBeenCalledOnce() + expect(unsubscribe).not.toHaveBeenCalled() + expect(terminal?.dispose).not.toHaveBeenCalled() + expect(terminal?.container?.style.transform).toBe('scale(0.75)') + expect(terminal?.container?.style.transformOrigin).toBe('top left') + expect(fit).toHaveBeenCalledExactlyOnceWith('pty-live', 60, 15) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx index 84c0242e186..8b30cb09d70 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.settings-lifetime.test.tsx @@ -53,7 +53,7 @@ vi.mock('./preview-grid-claim', () => ({ createPreviewGridClaim: () => ({ schedule: vi.fn(), dispose: vi.fn() }) })) vi.mock('./preview-terminal-box-fit', () => ({ - createPreviewBoxFit: () => ({ schedule: vi.fn() }) + createPreviewBoxFit: () => ({ schedule: vi.fn(), dispose: vi.fn() }) })) vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() })) vi.mock('./preview-terminal-compatibility', () => ({ diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 875f13a96f4..15cdaa1285c 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -33,8 +33,7 @@ import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview' const PREVIEW_SCROLLBACK_ROWS = 24 -// Why: main only ever serializes PREVIEW_SCROLLBACK_ROWS of history into this -// terminal, so the pane's user-configured scrollback would only cost memory. +// Preview snapshots bound history; pane scrollback would only cost memory. const PREVIEW_SCROLLBACK_BUFFER_ROWS = 1000 const FALLBACK_COLS = 80 const FALLBACK_ROWS = 24 @@ -130,7 +129,6 @@ export function AgentTerminalPreview({ const pendingLivePayloads: Extract[] = [] const boxFit = createPreviewBoxFit({ container, getTerminal: () => terminal }) - const scheduleFit = boxFit.schedule const gridClaim = createPreviewGridClaim({ ptyId, @@ -142,7 +140,7 @@ export function AgentTerminalPreview({ typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => { - scheduleFit() + boxFit.schedule() gridClaim.schedule() }) if (container.parentElement) { @@ -162,7 +160,7 @@ export function AgentTerminalPreview({ replayDepth++ terminal?.write(chunk, () => { replayDepth-- - scheduleFit() + boxFit.schedule() onDone?.() }) } @@ -317,7 +315,7 @@ export function AgentTerminalPreview({ // Queue behind every replay write so replacement never clears a half-parsed frame. writeReplayed('', requestRefresh) } - scheduleFit() + boxFit.schedule() gridClaim.schedule() terminal.focus() } @@ -392,6 +390,7 @@ export function AgentTerminalPreview({ return () => { disposed = true + boxFit.dispose() if (retryTimer) { clearTimeout(retryTimer) } diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts new file mode 100644 index 00000000000..e96d1eb1f29 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit-lifecycle.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/xterm' +import { createPreviewBoxFit } from './preview-terminal-box-fit' + +const frames = new Map() +const cancelFrame = vi.fn((id: number) => frames.delete(id)) +let nextFrame = 0 +const terminals: Terminal[] = [] + +beforeEach(() => { + nextFrame = 0 + frames.clear() + vi.clearAllMocks() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextFrame++ + frames.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', cancelFrame) +}) + +afterEach(() => { + for (const terminal of terminals.splice(0)) { + terminal.dispose() + } + vi.unstubAllGlobals() +}) + +function harness(getTerminal?: () => null) { + const box = document.createElement('div') + const container = document.createElement('div') + const screen = document.createElement('div') + screen.className = 'xterm-screen' + box.append(container) + container.append(screen) + Object.defineProperties(box, { + clientWidth: { value: 600 }, + clientHeight: { value: 240 } + }) + Object.defineProperties(screen, { + offsetWidth: { value: 800 }, + offsetHeight: { value: 384 } + }) + const terminal = new Terminal({ rows: 24, cols: 80, allowProposedApi: true }) + terminals.push(terminal) + const read = vi.fn(getTerminal ?? (() => terminal)) + return { ...createPreviewBoxFit({ container, getTerminal: read }), box, container, read } +} + +function flush(id = 0): void { + const callback = frames.get(id) + frames.delete(id) + callback?.(16) +} + +function thrown(operation: () => void): unknown { + try { + operation() + } catch (error) { + return error + } + throw new Error('Expected an error') +} + +it('keeps live geometry and coalesces repeated requests into one frame', () => { + const owner = harness() + owner.schedule() + owner.schedule() + owner.schedule() + expect(owner.read).not.toHaveBeenCalled() + expect(frames.size).toBe(1) + flush() + expect(owner.read).toHaveBeenCalledOnce() + expect(owner.container.style.transform).toBe('scale(0.75)') + expect(owner.container.style.transformOrigin).toBe('top left') + expect(owner.box.style.alignItems).toBe('flex-start') + owner.dispose() + expect(cancelFrame).not.toHaveBeenCalled() +}) + +it('cancels frame zero once and suppresses captured or later callbacks after disposal', () => { + const owner = harness() + owner.schedule() + const captured = frames.get(0) + owner.dispose() + owner.dispose() + captured?.(16) + owner.schedule() + expect(owner.read).not.toHaveBeenCalled() + expect(owner.container.style.transform).toBe('') + expect(frames.size).toBe(0) + expect(cancelFrame).toHaveBeenCalledExactlyOnceWith(0) +}) + +it('preserves synchronous frame completion without canceling a completed handle', () => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 0 + }) + const owner = harness() + owner.schedule() + owner.schedule() + expect(owner.read).toHaveBeenCalledTimes(2) + owner.dispose() + expect(cancelFrame).not.toHaveBeenCalled() +}) + +it('retains a reentrant successor frame and cancels only that pending successor', () => { + const owner = harness() + owner.read.mockImplementationOnce(() => { + owner.schedule() + return null + }) + owner.schedule() + flush() + expect(owner.read).toHaveBeenCalledOnce() + expect([...frames.keys()]).toEqual([1]) + owner.dispose() + expect(cancelFrame).toHaveBeenCalledExactlyOnceWith(1) +}) + +it('marks disposal before cancellation reenters schedule or invokes the retired callback', () => { + const owner = harness() + owner.schedule() + const callback = frames.get(0) + cancelFrame.mockImplementationOnce((id) => { + owner.schedule() + owner.dispose() + callback?.(16) + return frames.delete(id) + }) + owner.dispose() + expect(owner.read).not.toHaveBeenCalled() + expect(frames.size).toBe(0) + expect(cancelFrame).toHaveBeenCalledExactlyOnceWith(0) +}) + +it('preserves the original synchronous scheduling error and already-set coalescing flag', () => { + const error = new Error('scheduler failed') + const request = vi.fn(() => { + throw error + }) + vi.stubGlobal('requestAnimationFrame', request) + const owner = harness() + expect(thrown(owner.schedule)).toBe(error) + owner.schedule() + expect(request).toHaveBeenCalledOnce() + owner.dispose() + expect(cancelFrame).not.toHaveBeenCalled() +}) + +it('preserves a fit error and allows the next live request after completion', () => { + const error = new Error('terminal read failed') + const owner = harness() + owner.read.mockImplementationOnce(() => { + throw error + }) + owner.schedule() + expect(thrown(() => flush())).toBe(error) + owner.schedule() + flush(1) + expect(owner.read).toHaveBeenCalledTimes(2) + owner.dispose() + expect(cancelFrame).not.toHaveBeenCalled() +}) + +it('does not cancel another owner when both use the same container', () => { + const old = harness() + const current = createPreviewBoxFit({ container: old.container, getTerminal: () => null }) + old.schedule() + current.schedule() + old.dispose() + expect([...frames.keys()]).toEqual([1]) + current.dispose() + expect(cancelFrame.mock.calls).toEqual([[0], [1]]) +}) diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit.ts index ccf1f9f0da5..966c07983bf 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-box-fit.ts @@ -1,4 +1,5 @@ import type { Terminal } from '@xterm/xterm' +import { PaneReparentFrameTracker } from '@/lib/pane-manager/pane-reparent-frame-tracker' type PreviewBoxFitTerminal = Pick @@ -13,7 +14,9 @@ type PreviewBoxFitTerminal = Pick export function createPreviewBoxFit(args: { container: HTMLElement getTerminal: () => PreviewBoxFitTerminal | null -}): { fit: () => void; schedule: () => void } { +}): { fit: () => void; schedule: () => void; dispose: () => void } { + let disposed = false + const frames = new PaneReparentFrameTracker(() => disposed) const fit = (): void => { const terminal = args.getTerminal() const screen = args.container.querySelector('.xterm-screen') @@ -33,15 +36,25 @@ export function createPreviewBoxFit(args: { // Re-fit after every parsed write (cursor may move ends); rAF coalesces. let scheduled = false const schedule = (): void => { - if (scheduled) { + if (disposed || scheduled) { return } scheduled = true - requestAnimationFrame(() => { + frames.request(() => { scheduled = false fit() }) } - return { fit, schedule } + return { + fit, + schedule, + dispose: (): void => { + if (disposed) { + return + } + disposed = true + frames.cancelPending() + } + } } diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx index 2708e63a137..1c1e802e650 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx @@ -3,6 +3,16 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Selection } from 'monaco-editor' import type * as DiffCommentZoneCardModule from './diff-comment-zone-card' +import type { NotesSendMenu } from '../editor/NotesSendMenu' + +const notesMenuFixture = vi.hoisted(() => ({ + NotesSendMenu: vi.fn(() => { + throw new Error('Range selection must not render the agent notes menu') + }) +})) + +// Saved-note delivery is outside the selection and inline-draft paths exercised here. +vi.mock('../editor/NotesSendMenu', () => ({ NotesSendMenu: notesMenuFixture.NotesSendMenu })) const storeFixture = vi.hoisted(() => ({ activeGroupIdByWorktree: {}, @@ -146,7 +156,11 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals() document.body.replaceChildren() - vi.clearAllMocks() + try { + expect(notesMenuFixture.NotesSendMenu).not.toHaveBeenCalled() + } finally { + vi.clearAllMocks() + } }) describe('useDiffCommentDecorator range highlight', () => { diff --git a/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx b/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx new file mode 100644 index 00000000000..73f4091df0a --- /dev/null +++ b/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import CsvViewer from './CsvViewer' + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: Math.min(count, 3) }, (_, index) => ({ + index, + key: index, + start: index * 28 + })), + getTotalSize: () => count * 28 + }) +})) + +afterEach(cleanup) + +async function chooseDelimiter(label: string): Promise { + fireEvent.keyDown(screen.getByRole('combobox', { name: 'Delimiter' }), { key: 'ArrowDown' }) + fireEvent.click(await screen.findByRole('option', { name: label })) +} + +describe('CSV delimiter selection', () => { + it('lets the reader resolve ambiguous headerless decimal data', async () => { + render() + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Comma)') + expect(screen.getByRole('columnheader', { name: '50;coffee' })).toBeTruthy() + + await chooseDelimiter('Semicolon (;)') + + expect(screen.getByRole('columnheader', { name: '1,50' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '2,75' })).toBeTruthy() + expect(screen.getByRole('cell', { name: 'tea' })).toBeTruthy() + }) + + it('applies the selected separator to quoted multiline records', async () => { + render( + + ) + await chooseDelimiter('Semicolon (;)') + + expect(screen.getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual([ + '#', + 'multi\nline', + 'value' + ]) + expect(screen.getAllByRole('cell').map((cell) => cell.textContent)).toEqual([ + 'first\nsecond', + 'a;b' + ]) + }) + + it('allows an explicit comma separator even when the extension is tsv', async () => { + render() + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Tab)') + + await chooseDelimiter('Comma (,)') + + expect(screen.getByRole('columnheader', { name: 'amount' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '1,50' })).toBeTruthy() + }) + + it('keeps an explicit choice on refresh and can return to the current auto choice', async () => { + const { rerender } = render() + await chooseDelimiter('Semicolon (;)') + + rerender() + + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Semicolon (;)') + expect(screen.getAllByRole('columnheader')).toHaveLength(2) + await chooseDelimiter('Auto (Tab)') + expect(screen.getByRole('columnheader', { name: 'value' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '2' })).toBeTruthy() + }) + + it('resets to Auto when the editor mounts a different file identity', async () => { + const { rerender } = render( + + ) + await chooseDelimiter('Comma (,)') + + rerender() + + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Tab)') + expect(screen.getByRole('columnheader', { name: 'value' })).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/editor/CsvViewer.tsx b/src/renderer/src/components/editor/CsvViewer.tsx index e424a98c938..49d3a2ba51e 100644 --- a/src/renderer/src/components/editor/CsvViewer.tsx +++ b/src/renderer/src/components/editor/CsvViewer.tsx @@ -1,6 +1,7 @@ -import React, { useMemo, useRef } from 'react' +import React, { useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { detectCsvDelimiter, parseCsv } from './csv-parse' +import { CsvDelimiterPicker, type CsvDelimiterChoice } from './csv-delimiter-picker' import { translate } from '@/i18n/i18n' type CsvViewerProps = { @@ -23,11 +24,21 @@ const CHAR_PX = 7 // independently of the body, leaving values squashed together. export default function CsvViewer({ content, filePath }: CsvViewerProps): React.JSX.Element { const scrollRef = useRef(null) + const [delimiterChoice, setDelimiterChoice] = useState('auto') + const detectedDelimiter = useMemo( + () => detectCsvDelimiter(filePath, content), + [filePath, content] + ) + const delimiter = + delimiterChoice === 'auto' + ? detectedDelimiter + : delimiterChoice === 'comma' + ? ',' + : delimiterChoice === 'semicolon' + ? ';' + : '\t' - const parsed = useMemo(() => { - const delimiter = detectCsvDelimiter(filePath, content) - return parseCsv(content, delimiter) - }, [content, filePath]) + const parsed = useMemo(() => parseCsv(content, delimiter), [content, delimiter]) // Why: memoize header/body split so their references stay stable across // renders that don't change content. A top-level rest-destructure would @@ -178,7 +189,7 @@ export default function CsvViewer({ content, filePath }: CsvViewerProps): React.
-
+
{bodyRows.length.toLocaleString()}{' '} {translate('auto.components.editor.CsvViewer.ac31d2cd60', 'rows')} @@ -186,6 +197,11 @@ export default function CsvViewer({ content, filePath }: CsvViewerProps): React. {columnCount} {translate('auto.components.editor.CsvViewer.eedd0d37a7', 'columns')} +
) diff --git a/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx b/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx new file mode 100644 index 00000000000..d53b24baf9b --- /dev/null +++ b/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx @@ -0,0 +1,308 @@ +// @vitest-environment happy-dom +import { useMemo, useState } from 'react' +import type { editor } from 'monaco-editor' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { IpynbCellSource } from './IpynbCellEditor' +import { parseIpynb } from './ipynb-parse' +import { + getIpynbCellKey, + hasIpynbSourceDraft, + useIpynbDocumentEditing +} from './useIpynbDocumentEditing' + +const liveModels = vi.hoisted(() => { + const models: editor.ITextModel[] = [] + return models +}) +const widgetCalls = vi.hoisted(() => ({ + focus: vi.fn(), + layout: vi.fn(), + updateOptions: vi.fn<(options: editor.IEditorOptions) => void>() +})) + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { settings: undefined; editorFontZoomLevel: number }) => unknown + ) => selector({ settings: undefined, editorFontZoomLevel: 0 }) +})) +vi.mock('@/hooks/use-document-dark-theme', () => ({ useDocumentDarkTheme: () => true })) +vi.mock('./MonacoCodeExcerpt', () => ({ useMonacoColorizedLines: () => [] })) +vi.mock('./editor-shortcuts', () => ({ installMonacoEditorFindShortcut: () => () => {} })) +vi.mock('@/lib/monaco-setup', async () => { + const actual = await import('monaco-editor/esm/vs/editor/editor.api.js') + for (const id of ['python', 'javascript', 'markdown']) { + actual.languages.register({ id }) + } + return { + monaco: { + ...actual, + editor: { + ...actual.editor, + setTheme: vi.fn(), + createModel: (...args: Parameters) => { + const model = actual.editor.createModel(...args) + liveModels.push(model) + return model + }, + create: (_container: HTMLElement, { model }: { model: editor.ITextModel }) => ({ + getModel: () => model, + getContentHeight: () => 28, + layout: widgetCalls.layout, + onDidContentSizeChange: () => ({ dispose: vi.fn() }), + restoreViewState: vi.fn(), + saveViewState: () => null, + getTargetAtClientPoint: () => null, + setPosition: vi.fn(), + focus: widgetCalls.focus, + onDidBlurEditorWidget: () => ({ dispose: vi.fn() }), + addCommand: vi.fn(), + updateOptions: widgetCalls.updateOptions, + pushUndoStop: () => { + model.pushStackElement() + return true + }, + dispose: vi.fn() + }) + } + } + } +}) + +function notebookContent( + cells: { id: string; source: string; kind?: 'code' | 'markdown' }[], + language = 'python' +): string { + return JSON.stringify({ + nbformat: 4, + nbformat_minor: 5, + metadata: { language_info: { name: language } }, + cells: cells.map(({ id, source, kind }) => ({ + id, + cell_type: kind ?? 'code', + metadata: {}, + execution_count: null, + outputs: [], + source: [source] + })) + }) +} + +function NotebookCells({ + content, + onContentChange, + onDirtyStateHint +}: { + content: string + onContentChange: (content: string) => void + onDirtyStateHint: (dirty: boolean) => void +}): React.JSX.Element { + const notebook = useMemo(() => parseIpynb(content), [content]) + const [editingCellKey, setEditingCellKey] = useState(null) + const editing = useIpynbDocumentEditing({ + content, + fileId: 'external-notebook', + notebook, + onContentChange, + onDirtyStateHint, + onDeactivateEditor: () => setEditingCellKey(null) + }) + return ( +
+ {notebook.cells.map((cell, index) => { + const key = getIpynbCellKey(cell, index) + return ( + setEditingCellKey(key)} + onDeactivate={() => setEditingCellKey(null)} + onChange={(source) => editing.updateCellSource(index, source)} + /> + ) + })} +
+ ) +} + +function activeModel(): editor.ITextModel { + const model = liveModels.at(-1) + if (!model) { + throw new Error('No notebook cell editor was created') + } + return model +} + +function appendText(model: editor.ITextModel, text: string): void { + const end = model.getFullModelRange() + act(() => { + model.pushEditOperations( + [], + [{ range: { ...end, startLineNumber: end.endLineNumber, startColumn: end.endColumn }, text }], + () => null + ) + vi.advanceTimersByTime(400) + }) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() +}) +afterEach(() => { + cleanup() + for (const model of liveModels.splice(0)) { + if (!model.isDisposed()) { + model.dispose() + } + } + vi.useRealTimers() +}) + +describe('active notebook cell external reload', () => { + it('shows the new source without dirtying the clean notebook or recreating the model', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const initialContent = notebookContent([{ id: 'a', source: 'original source' }]) + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + expect(model.getValue()).toBe('original source') + expect(onDirtyStateHint).not.toHaveBeenCalled() + + rerender( + + ) + + expect(liveModels).toEqual([model]) + expect(model.getValue()).toBe('updated from disk') + expect(onContentChange).not.toHaveBeenCalled() + expect(onDirtyStateHint).not.toHaveBeenCalled() + expect(widgetCalls.focus).toHaveBeenCalledOnce() + appendText(model, '!') + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(committedContent).toBeDefined() + expect(parseIpynb(committedContent ?? '').cells[0]?.source).toBe('updated from disk!') + }) + + it('writes edits to the same cell after a clean external reload reorders it', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const first = { id: 'a', source: 'active source' } + const second = { id: 'b', source: 'other source' } + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: first.source }), { button: 0 }) + const model = activeModel() + expect(onDirtyStateHint).not.toHaveBeenCalled() + + rerender( + + ) + expect(liveModels).toEqual([model]) + expect(onDirtyStateHint).not.toHaveBeenCalled() + appendText(model, '!') + + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(committedContent).toBeDefined() + const cells = parseIpynb(committedContent ?? '').cells + expect(cells.map(({ id, source }) => ({ id, source }))).toEqual([ + second, + { ...first, source: 'active source!' } + ]) + }) + + it('retains undo for external source changes and treats undo as a user edit', async () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + rerender( + + ) + + expect(model.canUndo()).toBe(true) + expect(onDirtyStateHint).not.toHaveBeenCalled() + await act(async () => { + await model.undo() + vi.advanceTimersByTime(400) + }) + expect(model.getValue()).toBe('original source') + expect(onDirtyStateHint).toHaveBeenCalledWith(true) + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(parseIpynb(committedContent ?? '').cells[0]?.source).toBe('original source') + }) + + it('updates language, wrapping and height when a clean reload changes the active cell', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + rerender( + + ) + expect(model.getLanguageId()).toBe('javascript') + expect(widgetCalls.updateOptions).toHaveBeenLastCalledWith( + expect.objectContaining({ wordWrap: 'off' }) + ) + widgetCalls.layout.mockClear() + + rerender( + + ) + expect(liveModels).toEqual([model]) + expect(model.getLanguageId()).toBe('markdown') + expect(widgetCalls.updateOptions).toHaveBeenLastCalledWith( + expect.objectContaining({ wordWrap: 'on' }) + ) + expect(widgetCalls.layout).toHaveBeenCalled() + expect(model.getValue()).toBe('**Updated**\n\nParagraph') + expect(onDirtyStateHint).not.toHaveBeenCalled() + expect(onContentChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/IpynbCellEditor.tsx b/src/renderer/src/components/editor/IpynbCellEditor.tsx index 228bf57f6e3..e9b29768e44 100644 --- a/src/renderer/src/components/editor/IpynbCellEditor.tsx +++ b/src/renderer/src/components/editor/IpynbCellEditor.tsx @@ -5,6 +5,7 @@ import { monaco } from '@/lib/monaco-setup' import { computeEditorFontSize, resolveEditorFontStack } from '@/lib/editor-font-zoom' import { useAppStore } from '@/store' import { installMonacoEditorFindShortcut } from './editor-shortcuts' +import { syncContentUpdate } from './monaco-content-sync' import { IPYNB_CODE_CELL_PREVIEW_MAX_LINES, getIpynbCodeCellPreviewLines @@ -155,18 +156,21 @@ function IpynbSourceEditor({ const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel) const containerRef = useRef(null) const editorRef = useRef(null) + const callbacksRef = useRef({ onChange, onDeactivate }) + const syncingSourceRef = useRef(false) + const fitHeightRef = useRef<(() => void) | null>(null) + + useLayoutEffect(() => { + callbacksRef.current = { onChange, onDeactivate } + }, [onChange, onDeactivate]) useLayoutEffect(() => { monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs') }, [isDark]) - useLayoutEffect(() => { - editorRef.current?.updateOptions({ fontFamily, fontSize }) - }, [fontFamily, fontSize]) - // Why: created synchronously before paint (not via @monaco-editor/react's async loader), so the // swap from the preview never shows a placeholder, an unlaid-out editor or a guessed caret. - // Mount-once: the props it reads cannot change while the cell is being edited. + // Keep the model and undo history while external reloads update the cell below. useLayoutEffect(() => { const container = containerRef.current if (!container) { @@ -206,6 +210,7 @@ function IpynbSourceEditor({ container.style.height = `${Math.min(editorInstance.getContentHeight(), maxHeight)}px` editorInstance.layout() } + fitHeightRef.current = fitHeight fitHeight() editorInstance.onDidContentSizeChange(fitHeight) // Why: restoring a view state marks the visible lines stable, so Monaco tokenizes them now @@ -217,12 +222,16 @@ function IpynbSourceEditor({ editorInstance.setPosition(target.position) } editorInstance.focus() - model.onDidChangeContent(() => onChange(model.getValue())) - editorInstance.onDidBlurEditorWidget(onDeactivate) + model.onDidChangeContent(() => { + if (!syncingSourceRef.current) { + callbacksRef.current.onChange(model.getValue()) + } + }) + editorInstance.onDidBlurEditorWidget(() => callbacksRef.current.onDeactivate()) // Escape closes an open widget first; only a bare Escape leaves the cell. editorInstance.addCommand( monaco.KeyCode.Escape, - onDeactivate, + () => callbacksRef.current.onDeactivate(), '!suggestWidgetVisible && !findWidgetVisible && !parameterHintsVisible' ) const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance) @@ -231,9 +240,33 @@ function IpynbSourceEditor({ editorInstance.dispose() model.dispose() editorRef.current = null + fitHeightRef.current = null } // oxlint-disable-next-line react-hooks/exhaustive-deps -- mount-once; see the Why above. }, []) + useLayoutEffect(() => { + const editorInstance = editorRef.current + const model = editorInstance?.getModel() + if (!editorInstance || !model) { + return + } + if (model.getLanguageId() !== cell.language) { + monaco.editor.setModelLanguage(model, cell.language) + } + editorInstance.updateOptions({ + fontFamily, + fontSize, + wordWrap: cell.kind === 'code' ? 'off' : 'on' + }) + syncingSourceRef.current = true + try { + syncContentUpdate(editorInstance, source) + } finally { + syncingSourceRef.current = false + } + fitHeightRef.current?.() + }, [source, cell.language, cell.kind, fontFamily, fontSize]) + return
} diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index 8e2ca1e4abb..bf7fcc06936 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -20,6 +20,7 @@ import type { MarkdownReviewNote } from '@/lib/markdown-review-notes' import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations' import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' import type { DiffComment } from '../../../../shared/diff-comment-types' +import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus' function shouldFocusEmptyEditorFromSurfaceClick( event: React.MouseEvent, @@ -209,6 +210,7 @@ export function RichMarkdownEditorSurface({ // Image layout must not anchor-scroll over the restored tab position. className="relative h-full overflow-auto scrollbar-editor [overflow-anchor:none]" onMouseDown={(event) => { + focusRichMarkdownEditorFromSearch(event.nativeEvent, editor?.view ?? null) if (!shouldFocusEmptyEditorFromSurfaceClick(event, editor)) { return } diff --git a/src/renderer/src/components/editor/csv-delimiter-picker.tsx b/src/renderer/src/components/editor/csv-delimiter-picker.tsx new file mode 100644 index 00000000000..d345283fd39 --- /dev/null +++ b/src/renderer/src/components/editor/csv-delimiter-picker.tsx @@ -0,0 +1,68 @@ +import { useId } from 'react' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { translate } from '@/i18n/i18n' + +export type CsvDelimiterChoice = 'auto' | 'comma' | 'semicolon' | 'tab' + +export function CsvDelimiterPicker({ + value, + detectedDelimiter, + onChange +}: { + value: CsvDelimiterChoice + detectedDelimiter: string + onChange: (choice: CsvDelimiterChoice) => void +}): React.JSX.Element { + const id = useId() + const comma = translate('auto.components.editor.CsvViewer.delimiterComma', 'Comma') + const semicolon = translate('auto.components.editor.CsvViewer.delimiterSemicolon', 'Semicolon') + const tab = translate('auto.components.editor.CsvViewer.delimiterTab', 'Tab') + const detectedName = + detectedDelimiter === ',' ? comma : detectedDelimiter === ';' ? semicolon : tab + const choices = [ + { + value: 'auto', + label: translate('auto.components.editor.CsvViewer.delimiterAuto', 'Auto ({{delimiter}})', { + delimiter: detectedName + }) + }, + { value: 'comma', label: `${comma} (,)` }, + { value: 'semicolon', label: `${semicolon} (;)` }, + { value: 'tab', label: tab } + ] as const + + return ( +
+ + +
+ ) +} diff --git a/src/renderer/src/components/editor/csv-parse.test.ts b/src/renderer/src/components/editor/csv-parse.test.ts index 267be8b1028..f2e935e4937 100644 --- a/src/renderer/src/components/editor/csv-parse.test.ts +++ b/src/renderer/src/components/editor/csv-parse.test.ts @@ -80,6 +80,130 @@ describe('detectCsvDelimiter', () => { expect(detectCsvDelimiter('data.csv', 'a,b,c\n1,2,3')).toBe(',') }) + it('sniffs semicolon exports that use commas as the decimal mark', () => { + const content = 'amount;label\n1,50;"Roe, John"\n2,75;lunch\n' + + expect(detectCsvDelimiter('expenses.csv', content)).toBe(';') + expect(parseCsv(content, detectCsvDelimiter('expenses.csv', content)).rows).toEqual([ + ['amount', 'label'], + ['1,50', 'Roe, John'], + ['2,75', 'lunch'] + ]) + }) + + it('keeps comma when semicolons only appear inside quoted fields', () => { + expect(detectCsvDelimiter('x.csv', '"a"";b;c",d,e\n1,2,3\n')).toBe(',') + }) + + it('prefers tab over semicolon when tabs dominate the first line', () => { + expect(detectCsvDelimiter('x.csv', 'a\tb;c\td\n')).toBe('\t') + }) + + it('uses tab for .tsv files that contain semicolons', () => { + expect(detectCsvDelimiter('data.TSV', 'a;b;c')).toBe('\t') + }) + + it.each([ + ['a;b,c', ','], + ['a;b,c\td', ','], + ['a;b\tc', '\t'], + ['a,b\tc', ','], + ['', ','], + ['single', ','] + ])('preserves existing delimiter precedence for %j', (content, delimiter) => { + expect(detectCsvDelimiter('x.csv', content)).toBe(delimiter) + }) + + it('sniffs semicolons after a BOM and leading whitespace-only lines', () => { + expect(detectCsvDelimiter('x.csv', '\uFEFF\n \t \r\na;b\n1;2')).toBe(';') + }) + + it('parses semicolon fields with quoted separators, escaped quotes and newlines', () => { + const content = '\uFEFFnote;amount\r\n"she said ""hi"";\nnext";1,50\r\n' + + expect(parseCsv(content, detectCsvDelimiter('x.csv', content))).toEqual({ + rows: [ + ['note', 'amount'], + ['she said "hi";\nnext', '1,50'] + ], + maxColumns: 2 + }) + }) + + it('keeps consistent comma columns when only the header has extra semicolons', () => { + const content = 'notes;one;two,value\nplain,1\nother,2' + + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + expect(parseCsv(content, detectCsvDelimiter('x.csv', content)).rows).toEqual([ + ['notes;one;two', 'value'], + ['plain', '1'], + ['other', '2'] + ]) + }) + + it.each([',', '\t'])('keeps literal quotes inside unquoted %j fields', (delimiter) => { + const content = `notes;one;two${delimiter}value\n6" bolts${delimiter}1\nplain${delimiter}2` + + expect(detectCsvDelimiter('x.csv', content)).toBe(delimiter) + expect(parseCsv(content, detectCsvDelimiter('x.csv', content)).rows).toEqual([ + ['notes;one;two', 'value'], + ['6" bolts', '1'], + ['plain', '2'] + ]) + }) + + it('keeps consistent tab columns when a header contains extra semicolons', () => { + expect(detectCsvDelimiter('x.csv', 'notes;one;two\tvalue\nplain\t1')).toBe('\t') + }) + + it('keeps a semicolon export whose unquoted comma counts vary between rows', () => { + expect(detectCsvDelimiter('x.csv', 'name;amount;note\nAda;1,50;lunch\nBo;2;plain')).toBe(';') + }) + + it('ignores separator-like punctuation in a multiline quoted field', () => { + const content = 'notes;one;two,value\n"line one\nline;two",1' + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('keeps first-line inference when the rows are ambiguous or ragged', () => { + expect(detectCsvDelimiter('x.csv', 'a;b;c,value\nx;y;z,1')).toBe(';') + expect(detectCsvDelimiter('x.csv', 'a;b;c\nx;y\nz')).toBe(';') + expect(detectCsvDelimiter('x.csv', '1,50;coffee\n2,75;lunch')).toBe(',') + }) + + it('uses at most eight logical records to corroborate the existing delimiter', () => { + const firstEight = ['notes;one;two,value', ...Array.from({ length: 7 }, () => 'plain,1')] + const content = [...firstEight, 'ragged,one,two,three'].join('\n') + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('does not use an unfinished quoted record at the scan boundary as corroboration', () => { + const prefix = 'notes;one;two,value\nplain,"' + const content = `${prefix}${'x'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)}",1` + expect(detectCsvDelimiter('x.csv', content)).toBe(';') + }) + + it('does not use a partial unquoted record at the scan boundary as corroboration', () => { + const prefix = 'notes;one;two,value\nplain,' + const content = `${prefix}${'x'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)}\nother,2` + expect(detectCsvDelimiter('x.csv', content)).toBe(';') + }) + + it('does not corroborate an unfinished quoted record at EOF', () => { + expect(detectCsvDelimiter('x.csv', 'notes;one;two,value\nplain,"unfinished')).toBe(';') + }) + + it('corroborates complete records with CRLF, escaped quotes and a quoted newline', () => { + const content = 'notes;one;two,value\r\n"say ""hi"";\r\nnext",1\r\nplain,2\r\n' + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('does not sniff semicolons beyond the first-line scan limit', () => { + const content = `${'a'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)};b;c` + + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + it('skips leading blank lines when sniffing', () => { expect(detectCsvDelimiter('x.csv', '\n\na\tb\tc')).toBe('\t') }) diff --git a/src/renderer/src/components/editor/csv-parse.ts b/src/renderer/src/components/editor/csv-parse.ts index 009e9646320..6f52edbf5b7 100644 --- a/src/renderer/src/components/editor/csv-parse.ts +++ b/src/renderer/src/components/editor/csv-parse.ts @@ -107,9 +107,7 @@ export function detectCsvDelimiter(filePath: string, content: string): string { if (filePath.toLowerCase().endsWith('.tsv')) { return '\t' } - // Why: sniff the first non-empty line for tab vs comma to handle CSVs that - // were saved with a different extension. Semicolons/pipes are out of scope; - // this tool is a viewer, not a general data importer. + // Include semicolon spreadsheet exports without adding general importer heuristics. // Why: strip a leading UTF-8 BOM so it doesn't get counted as part of the // first cell's characters (and so BOM-prefixed TSVs still sniff correctly). let text = content @@ -121,8 +119,84 @@ export function detectCsvDelimiter(filePath: string, content: string): string { // (0 tabs vs 0 commas, tie goes to comma), misdetecting blank-leading TSVs. const firstLine = findFirstNonEmptyCsvSniffLine(text) const tabs = countDelimiterOutsideQuotes(firstLine, '\t') + const semicolons = countDelimiterOutsideQuotes(firstLine, ';') const commas = countDelimiterOutsideQuotes(firstLine, ',') - return tabs > commas ? '\t' : ',' + // Keep the existing comma/tab choice unless semicolon strictly wins. + const existingDelimiter = tabs > commas ? '\t' : ',' + if (semicolons > commas && semicolons > tabs) { + return hasConsistentExistingCsvColumns(text, existingDelimiter) ? existingDelimiter : ';' + } + return existingDelimiter +} + +// Header punctuation should not replace an otherwise consistent comma/tab table. +function hasConsistentExistingCsvColumns(text: string, delimiter: string): boolean { + const scanLength = Math.min(text.length, CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS) + const records: { delimiters: number; semicolons: number }[] = [] + let delimiters = 0 + let semicolons = 0 + let inQuotes = false + let fieldIsEmpty = true + let hasContent = false + const pushRecord = (): void => { + if (hasContent) { + records.push({ delimiters, semicolons }) + } + delimiters = 0 + semicolons = 0 + fieldIsEmpty = true + hasContent = false + } + + for (let index = 0; index < scanLength && records.length < 8; index += 1) { + const ch = text[index] + if (inQuotes) { + if (ch === '"') { + if (text[index + 1] === '"' && index + 1 < scanLength) { + fieldIsEmpty = false + index += 1 + } else { + inQuotes = false + } + } else { + fieldIsEmpty = false + } + continue + } + if (ch === '"' && fieldIsEmpty) { + inQuotes = true + hasContent = true + continue + } + if (ch === '\r' || ch === '\n') { + pushRecord() + if (ch === '\r' && text[index + 1] === '\n') { + index += 1 + } + continue + } + if (ch === delimiter) { + delimiters += 1 + fieldIsEmpty = true + } else { + fieldIsEmpty = false + } + if (ch === ';') { + semicolons += 1 + } + hasContent ||= !isCsvSniffWhitespace(text.charCodeAt(index)) + } + if (scanLength === text.length && records.length < 8 && !inQuotes) { + pushRecord() + } + const first = records[0] + return Boolean( + first && + first.delimiters > 0 && + records.length > 1 && + records.every((record) => record.delimiters === first.delimiters) && + records.some((record) => record.semicolons !== first.semicolons) + ) } function findFirstNonEmptyCsvSniffLine(text: string): string { diff --git a/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts b/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts index 68e33bc6baf..18bc3b6ce2c 100644 --- a/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts +++ b/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts @@ -22,6 +22,36 @@ function file(overrides: Partial & Pick): } describe('editor external watch path batch index', () => { + it('routes POSIX literal-backslash updates only to the matching tab', () => { + const scope = { worktreeId: 'wt-posix', worktreePath: '/repo', runtimeEnvironmentId: null } + const literal = file({ + id: 'literal', + worktreeId: scope.worktreeId, + filePath: '/repo/a\\b.txt', + relativePath: 'a\\b.txt' + }) + const nested = file({ + id: 'nested', + worktreeId: scope.worktreeId, + filePath: '/repo/a/b.txt', + relativePath: 'a/b.txt' + }) + const index = indexEditorExternalWatchBatchPaths( + { + worktreePath: scope.worktreePath, + events: [ + { kind: 'update', absolutePath: literal.filePath }, + { kind: 'update', absolutePath: nested.filePath } + ] + }, + [literal, nested], + scope + ) + expect(index.changes.map((change) => change.relativePath)).toEqual(['a\\b.txt', 'a/b.txt']) + expect(index.matchingOpenFiles(index.changes[0])).toEqual([literal]) + expect(index.matchingOpenFiles(index.changes[1])).toEqual([nested]) + }) + it('matches UNC aliases for updates, deletes, and restored tombstones', () => { const restored = file({ id: 'restored', diff --git a/src/renderer/src/components/editor/markdown-preview-review-frame-lifecycle.test.tsx b/src/renderer/src/components/editor/markdown-preview-review-frame-lifecycle.test.tsx new file mode 100644 index 00000000000..e25ee2e2e70 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-review-frame-lifecycle.test.tsx @@ -0,0 +1,457 @@ +// @vitest-environment happy-dom +import { act, StrictMode, type MutableRefObject } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation' +import type { VirtualMarkdownPreviewNavigation } from './VirtualMarkdownPreviewBody' +import { + useMarkdownPreviewReviewActions, + type MarkdownPreviewReviewActions +} from './use-markdown-preview-review-actions' +import { + useMarkdownPreviewViewport, + type MarkdownPreviewViewport +} from './use-markdown-preview-viewport' +import { + cancelMarkdownPreviewEditorRevealFrames, + requestMarkdownPreviewEditorRevealFrame +} from './markdown-preview-editor-reveal' + +function ref(current: T): MutableRefObject { + return { current } +} + +function frameQueue() { + let nextId = 0 + const pending = new Map() + const cancel = vi.fn((id: number) => pending.delete(id)) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = ++nextId + pending.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', cancel) + return { + pending, + cancel, + flush: () => { + for (const [id, callback] of Array.from(pending)) { + pending.delete(id) + callback(16) + } + } + } +} + +const cleanups = new Set<() => void>() + +function mountSurface( + strict = false, + largeNavigationRef?: MutableRefObject +) { + const comment: DiffComment = { + id: 'note', + worktreeId: 'folder', + filePath: 'readme.md', + lineNumber: 1, + body: 'review', + createdAt: 1, + side: 'modified' + } + const attention = vi.fn() + const active = vi.fn() + const fixture = { + rootRef: ref(null), + renderedContent: 'review text', + markdownReviewNotes: [], + reviewNotesCopyMountedRef: ref(false), + setReviewNotesCopied: vi.fn(), + reviewNotesCopiedResetTimerRef: ref(null), + setCopiedReviewNoteId: vi.fn(), + copiedReviewNoteResetTimerRef: ref(null), + attentionReviewCommentTimeoutRef: ref(null), + pendingReviewActionFrameIdsRef: ref([]), + pendingReviewActionTimeoutIdsRef: ref([]), + reviewActionFrameGenerationRef: ref(0), + setAttentionReviewCommentId: attention, + setActiveReviewCommentId: active, + markdownComments: [comment], + activeReviewCommentId: null, + bodyRef: ref(null), + inputRef: ref(null), + matchesRef: ref([]), + searchInstanceRef: ref({}), + lastAppliedInitialAnchorRef: ref(null), + pendingEditorRevealFrameIdsRef: ref([]), + isSearchOpen: false, + setIsSearchOpen: vi.fn(), + query: '', + setQuery: vi.fn(), + matchCount: 0, + setMatchCount: vi.fn(), + searchRevision: 0, + setSearchRevision: vi.fn(), + activeMatchIndex: -1, + setActiveMatchIndex: vi.fn(), + keybindings: {}, + activeAnnotationBlockKeyRef: ref(null), + setActiveAnnotationBlockKey: vi.fn() + } + const usedFoundation: Pick = fixture + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The checked fixture supplies every member read by the actual viewport and review-action hooks. + const foundation = usedFoundation as unknown as MarkdownPreviewFoundation + let actions: MarkdownPreviewReviewActions | undefined + let viewport: MarkdownPreviewViewport | undefined + function Surface() { + viewport = useMarkdownPreviewViewport({ + foundation, + scrollCacheKey: 'review-frame-test', + initialAnchor: null, + content: '', + largePreview: largeNavigationRef !== undefined, + largeNavigationRef, + markdownAnnotationsEnabled: true + }) + actions = useMarkdownPreviewReviewActions({ foundation, viewport }) + return ( +
+
+
+ ) + } + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + act(() => + root.render( + strict ? ( + + + + ) : ( + + ) + ) + ) + const cleanup = () => { + act(() => root.unmount()) + host.remove() + cleanups.delete(cleanup) + } + cleanups.add(cleanup) + if (!actions || !viewport || !foundation.rootRef.current) { + throw new Error('Missing mounted review surface') + } + const card = foundation.rootRef.current.querySelector( + '[data-markdown-review-note-id]' + ) + if (!card) { + throw new Error('Missing rendered review card') + } + const scroll = vi.spyOn(card, 'scrollIntoView') + const currentActions = actions + const click = () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Annotation navigation reads only target from the React event. + const event = { target: foundation.rootRef.current } as unknown as React.MouseEvent + currentActions.handleAnnotatedMarkdownBlockClick({ startLine: 1, endLine: 1 }, event) + } + return { + foundation, + viewport, + actions: currentActions, + comment, + attention, + active, + scroll, + click, + cleanup + } +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) +}) + +afterEach(() => { + for (const cleanup of cleanups) { + cleanup() + } + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('Markdown review frame lifetime', () => { + it('keeps virtual source-line navigation immediate while retiring old review frames', () => { + const frames = frameQueue() + const order: string[] = [] + const sourceLine = vi.fn((line: number) => { + order.push(`line:${line}`) + return true + }) + const navigationRef = ref({ + anchor: vi.fn(() => true), + sourceLine, + search: vi.fn() + }) + const surface = mountSurface(false, navigationRef) + const node = surface.foundation.rootRef.current + if (!node) { + throw new Error('Missing virtual review surface') + } + const queried = vi.spyOn(node, 'querySelectorAll') + surface.active.mockImplementation((id: string) => order.push(`active:${id}`)) + surface.actions.scrollToReviewNote(surface.comment) + expect(order).toEqual(['active:note', 'line:1']) + expect(sourceLine.mock.calls).toEqual([[1]]) + expect(queried).not.toHaveBeenCalled() + expect(surface.attention).not.toHaveBeenCalled() + expect(frames.pending.size).toBe(0) + + surface.click() + const callbacks = Array.from(frames.pending.values()) + expect(callbacks).toHaveLength(2) + surface.viewport.setRootRef(null) + for (const callback of callbacks) { + callback(16) + } + expect(frames.pending.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + expect(surface.attention.mock.calls).toEqual([[null]]) + expect(surface.scroll).not.toHaveBeenCalled() + expect(sourceLine).toHaveBeenCalledOnce() + + const successorSourceLine = vi.fn(() => true) + navigationRef.current = { + anchor: vi.fn(() => true), + sourceLine: successorSourceLine, + search: vi.fn() + } + surface.viewport.setRootRef(node) + surface.actions.scrollToReviewNote(surface.comment) + for (const callback of callbacks) { + callback(16) + } + expect(successorSourceLine.mock.calls).toEqual([[1]]) + expect(surface.active.mock.calls).toEqual([['note'], ['note'], ['note']]) + expect(surface.attention.mock.calls).toEqual([[null]]) + expect(surface.scroll).not.toHaveBeenCalled() + expect(frames.pending.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + + successorSourceLine.mockReturnValue(false) + surface.actions.scrollToReviewNote(surface.comment) + navigationRef.current = null + surface.actions.scrollToReviewNote(surface.comment) + expect(surface.scroll.mock.calls).toEqual([ + [{ behavior: 'smooth', block: 'center' }], + [{ behavior: 'smooth', block: 'center' }] + ]) + expect(frames.pending.size).toBe(0) + }) + + it('leaves no suspended review callbacks after 64 actual surface unmounts', () => { + const frames = frameQueue() + for (let index = 0; index < 64; index += 1) { + const surface = mountSurface() + surface.click() + surface.cleanup() + expect(surface.foundation.rootRef.current).toBeNull() + expect(surface.foundation.reviewNotesCopyMountedRef.current).toBe(false) + } + expect(frames.pending.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([false, true])( + 'preserves both live frames and the exact 900 ms pulse in StrictMode=%s', + (strict) => { + const frames = frameQueue() + const surface = mountSurface(strict) + surface.click() + expect(surface.active.mock.calls).toEqual([['note']]) + expect(surface.attention.mock.calls).toEqual([[null]]) + expect(surface.scroll).not.toHaveBeenCalled() + expect(frames.pending.size).toBe(2) + frames.flush() + expect(surface.attention.mock.calls).toEqual([[null], ['note']]) + expect(surface.scroll.mock.calls).toEqual([ + [{ behavior: 'smooth', block: 'center', inline: 'nearest' }] + ]) + expect(frames.pending.size).toBe(0) + expect(surface.foundation.pendingReviewActionFrameIdsRef.current).toEqual([]) + vi.advanceTimersByTime(899) + expect(surface.attention).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(1) + expect(surface.attention.mock.calls).toEqual([[null], ['note'], [null]]) + expect(surface.foundation.attentionReviewCommentTimeoutRef.current).toBeNull() + surface.cleanup() + expect(frames.cancel).not.toHaveBeenCalled() + } + ) + + it('skips captured callbacks and late actions after unmount without starting another timer', () => { + const frames = frameQueue() + const surface = mountSurface() + surface.click() + const callbacks = Array.from(frames.pending.values()) + surface.cleanup() + for (const callback of callbacks) { + callback(16) + } + surface.click() + expect(surface.attention.mock.calls).toEqual([[null]]) + expect(surface.active.mock.calls).toEqual([['note']]) + expect(surface.scroll).not.toHaveBeenCalled() + expect(frames.pending.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([false, true])('preserves every live pulse with teardown before expiry=%s', (dispose) => { + const frames = frameQueue() + const surface = mountSurface() + surface.click() + surface.click() + expect(frames.pending.size).toBe(4) + frames.flush() + expect(surface.attention.mock.calls).toEqual([[null], [null], ['note'], ['note']]) + expect(surface.scroll).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(2) + vi.advanceTimersByTime(899) + expect(surface.attention).toHaveBeenCalledTimes(4) + if (!dispose) { + vi.advanceTimersByTime(1) + expect(surface.attention.mock.calls).toEqual([ + [null], + [null], + ['note'], + ['note'], + [null], + [null] + ]) + } + surface.cleanup() + expect(vi.getTimerCount()).toBe(0) + expect(surface.foundation.pendingReviewActionTimeoutIdsRef.current).toEqual([]) + }) + + it('does not revive an old timer or clear a successor after same-node replacement', () => { + const frames = frameQueue() + const scheduled = vi.spyOn(window, 'setTimeout') + const surface = mountSurface() + const node = surface.foundation.rootRef.current + surface.click() + frames.flush() + const timeout = scheduled.mock.calls.find((args) => args[1] === 900)?.[0] + if (typeof timeout !== 'function') { + throw new Error('Missing pulse timeout') + } + surface.viewport.setRootRef(null) + surface.viewport.setRootRef(node) + surface.click() + frames.flush() + const successor = surface.foundation.attentionReviewCommentTimeoutRef.current + timeout() + expect(surface.attention.mock.calls).toEqual([[null], ['note'], [null], ['note']]) + expect(surface.foundation.attentionReviewCommentTimeoutRef.current).toBe(successor) + expect(vi.getTimerCount()).toBe(1) + }) + + it('keeps editor-navigation cancellation separate from live review frames', () => { + const frames = frameQueue() + const surface = mountSurface() + const editorReveal = vi.fn() + requestMarkdownPreviewEditorRevealFrame( + surface.foundation.pendingEditorRevealFrameIdsRef, + editorReveal + ) + surface.click() + expect(frames.pending.size).toBe(3) + cancelMarkdownPreviewEditorRevealFrames(surface.foundation.pendingEditorRevealFrameIdsRef) + expect(frames.pending.size).toBe(2) + frames.flush() + expect(editorReveal).not.toHaveBeenCalled() + expect(surface.scroll).toHaveBeenCalledOnce() + expect(surface.attention.mock.calls).toEqual([[null], ['note']]) + }) + + it('keeps a successor attached to the same DOM node independent of old callbacks', () => { + const frames = frameQueue() + const surface = mountSurface() + const node = surface.foundation.rootRef.current + surface.click() + const callbacks = Array.from(frames.pending.values()) + surface.viewport.setRootRef(null) + surface.viewport.setRootRef(node) + surface.click() + for (const callback of callbacks) { + callback(16) + } + expect(frames.pending.size).toBe(2) + expect(surface.attention.mock.calls).toEqual([[null], [null]]) + expect(vi.getTimerCount()).toBe(0) + frames.flush() + expect(surface.attention.mock.calls).toEqual([[null], [null], ['note']]) + expect(surface.scroll).toHaveBeenCalledOnce() + }) + + it('does not cancel successor frames queued reentrantly by old frame cancellation', () => { + const frames = frameQueue() + const surface = mountSurface() + const node = surface.foundation.rootRef.current + surface.click() + frames.cancel.mockImplementationOnce((id) => { + frames.pending.delete(id) + surface.viewport.setRootRef(node) + surface.click() + return true + }) + surface.viewport.setRootRef(null) + expect(frames.pending.size).toBe(2) + expect(surface.foundation.pendingReviewActionFrameIdsRef.current).toHaveLength(2) + frames.flush() + expect(surface.scroll).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(1) + }) + + it.each(['active', 'reset', 'pulse'])( + 'does not schedule new work after reentrant disposal in %s setter', + (setter) => { + const frames = frameQueue() + const surface = mountSurface() + if (setter === 'active') { + surface.active.mockImplementationOnce(surface.cleanup) + } + if (setter === 'reset') { + surface.attention.mockImplementationOnce(surface.cleanup) + } + if (setter === 'pulse') { + surface.attention.mockImplementationOnce(() => {}).mockImplementationOnce(surface.cleanup) + } + surface.click() + frames.flush() + expect(frames.pending.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + expect(surface.scroll).not.toHaveBeenCalled() + } + ) + + it('drops completed frame references with a synchronous frame shim', () => { + const frames = frameQueue() + let nextId = 0 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return ++nextId + }) + const surface = mountSurface() + surface.click() + expect(surface.foundation.pendingReviewActionFrameIdsRef.current).toEqual([]) + expect(surface.scroll).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(1) + surface.cleanup() + expect(frames.cancel).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-preview-review-timer-cleanup.ts b/src/renderer/src/components/editor/markdown-preview-review-timer-cleanup.ts new file mode 100644 index 00000000000..feef6af2759 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-review-timer-cleanup.ts @@ -0,0 +1,17 @@ +import type { MutableRefObject } from 'react' + +export function clearMarkdownPreviewReviewTimers( + latestTimeoutRef: MutableRefObject, + pendingTimeouts: readonly number[] +): void { + const latestTimeout = latestTimeoutRef.current + if (latestTimeout !== null) { + window.clearTimeout(latestTimeout) + latestTimeoutRef.current = null + } + for (const timeout of pendingTimeouts) { + if (timeout !== latestTimeout) { + window.clearTimeout(timeout) + } + } +} diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts new file mode 100644 index 00000000000..a416d6719f0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts @@ -0,0 +1,216 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Editor } from '@tiptap/core' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { + buildRichMarkdownCommentBlocks, + getRichMarkdownCommentBlocks +} from './rich-markdown-comment-blocks' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { + createRichMarkdownHtmlSuperscriptLinkContext, + type RichMarkdownHtmlSuperscriptLinkContext +} from './rich-markdown-html-superscript-link-context' +import { + getRichMarkdownAnnotationHighlightRanges, + getRichMarkdownAnnotationHighlightRangesForComment, + getRichMarkdownAnnotationTarget, + getRichMarkdownCommentAtPos +} from './rich-markdown-review-annotations' +import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks' + +const editors: Editor[] = [] +const SOURCE = 'First paragraph\n\n```ts\none\n\ntwo\n```\n\nLast paragraph' + +function createEditor(source = SOURCE, context?: RichMarkdownHtmlSuperscriptLinkContext) { + const root = document.createElement('div') + document.body.append(root) + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: root, + extensions: createRichMarkdownExtensions({ + codec, + htmlSuperscriptLinks: Boolean(context), + htmlSuperscriptLinkContext: context + }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec, { + htmlSuperscriptLinks: Boolean(context) + }), + contentType: 'markdown' + }) + editors.push(editor) + editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false)) + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 600, 400)) + return { editor, root } +} + +function selectLastParagraph(editor: Editor, characters = 4) { + let from: number | undefined + editor.state.doc.forEach((node, offset) => { + if (node.textContent === 'Last paragraph') { + from = offset + 1 + } + }) + if (from === undefined) { + throw new Error('Last paragraph missing') + } + editor.commands.setTextSelection({ from, to: from + characters }) + const paragraph = Array.from(editor.view.dom.querySelectorAll('p')).find( + (element) => element.textContent === 'Last paragraph' + ) + const text = paragraph?.firstChild + if (!(text instanceof Text)) { + throw new Error('Last paragraph text missing') + } + const range = document.createRange() + range.setStart(text, 0) + range.setEnd(text, characters) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + return from +} + +function comment(lineNumber = 29): DiffComment { + return { + id: 'note', + worktreeId: 'workspace', + filePath: 'notes.md', + source: 'markdown', + lineNumber, + selectedText: 'Last', + body: 'Review', + createdAt: 1, + side: 'modified' + } +} + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.destroy() + } + window.getSelection()?.removeAllRanges() + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('Markdown review source block reuse', () => { + it('preserves source lines when the document interaction host changes', () => { + const context = createRichMarkdownHtmlSuperscriptLinkContext({ + sourceFilePath: '/repo/notes.md', + worktreeId: 'workspace', + worktreeRoot: '/repo', + sourceOwner: { kind: 'local' } + }) + const { editor } = createEditor( + 'First [1]\n\nLast paragraph', + context + ) + const blocks = getRichMarkdownCommentBlocks(editor) + const doc = editor.state.doc + const serialize = vi.spyOn(editor.markdown!, 'serialize') + const instrumentedBlocks = getRichMarkdownCommentBlocks(editor) + serialize.mockClear() + context.update({ + sourceFilePath: '/remote/notes.md', + worktreeId: 'remote-workspace', + worktreeRoot: '/remote', + sourceOwner: { kind: 'ssh', connectionId: 'connection' } + }) + expect(editor.state.doc).toBe(doc) + expect(getRichMarkdownCommentBlocks(editor)).toBe(instrumentedBlocks) + expect(serialize).not.toHaveBeenCalled() + serialize.mockRestore() + expect(buildRichMarkdownCommentBlocks(editor)).toEqual(blocks) + }) + + it('shares one source map across highlights, comment clicks, selection targets and the rail', () => { + const { editor, root } = createEditor() + const from = selectLastParagraph(editor) + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + const json = vi.spyOn(editor, 'getJSON') + const note = comment() + const expected = [{ from, to: from + 4 }] + + expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + expect(json).toHaveBeenCalledTimes(1) + serialize.mockClear() + json.mockClear() + + for (let index = 0; index < 20; index++) { + expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) + expect(getRichMarkdownAnnotationHighlightRangesForComment(editor, note, 20)).toEqual(expected) + expect(getRichMarkdownCommentAtPos(editor, [note], 20, from + 2)).toBe(note) + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + from, + to: from + 4, + selectedText: 'Last', + lineNumber: 9 + }) + expect(getRichMarkdownReviewRailBlocks(editor)).toBe(getRichMarkdownCommentBlocks(editor)) + } + expect(serialize).not.toHaveBeenCalled() + expect(json).not.toHaveBeenCalled() + }) + + it('updates selected text and viewport geometry without rebuilding source lines', () => { + const { editor, root } = createEditor() + const rect = vi.spyOn(Range.prototype, 'getBoundingClientRect') + rect.mockReturnValue(new DOMRect(10, 20, 80, 20)) + selectLastParagraph(editor) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + selectedText: 'Last', + buttonTop: 48, + lineNumber: 9 + }) + serialize.mockClear() + const doc = editor.state.doc + selectLastParagraph(editor, 9) + rect.mockReturnValue(new DOMRect(10, 120, 140, 20)) + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + selectedText: 'Last para', + buttonTop: 148, + lineNumber: 9 + }) + expect(editor.state.doc).toBe(doc) + expect(serialize).not.toHaveBeenCalled() + }) + + it('rebuilds source lines after editing a multiline block and undoing it', () => { + const { editor, root } = createEditor() + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + let codeFrom: number | undefined + editor.state.doc.forEach((node, offset) => { + if (node.type.name === 'codeBlock') { + codeFrom = offset + 1 + } + }) + if (codeFrom === undefined) { + throw new Error('Code block missing') + } + editor.view.dispatch(editor.state.tr.insertText('new\n', codeFrom)) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(10) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + expect( + getRichMarkdownCommentAtPos(editor, [comment(30)], 20, editor.state.selection.from) + ).toEqual(comment(30)) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + serialize.mockClear() + + expect(editor.commands.undo()).toBe(true) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + serialize.mockClear() + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + expect(serialize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts new file mode 100644 index 00000000000..261b982f0bd --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts @@ -0,0 +1,82 @@ +import type { Editor, JSONContent } from '@tiptap/core' +import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' + +export type RichMarkdownCommentBlock = { + key: string + startLine: number + endLine: number + from: number + to: number +} + +function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { + return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() +} + +export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { + const jsonContent = editor.getJSON().content ?? [] + const blocks: RichMarkdownCommentBlock[] = [] + let nextLine = 1 + let previousNodeJson: JSONContent | null = null + let previousNodeLineCount = 0 + + editor.state.doc.forEach((node, nodeOffset, index) => { + const nodeJson = jsonContent[index] + if (!nodeJson) { + return + } + const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) + const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) + if (previousNodeJson) { + const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) + const separatorLineCount = Math.max( + 0, + countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount + ) + nextLine += separatorLineCount + } + const startLine = nextLine + const endLine = Math.max(startLine, startLine + nodeLineCount - 1) + const from = nodeOffset + 1 + blocks.push({ + key: `${index}:${startLine}-${endLine}`, + startLine, + endLine, + from, + to: from + Math.max(0, node.nodeSize - 1) + }) + nextLine = endLine + 1 + previousNodeJson = nodeJson + previousNodeLineCount = nodeLineCount + }) + + if (blocks.length === 0) { + blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) + } + + return blocks +} + +type RichMarkdownCommentBlocksSnapshot = { + doc: Editor['state']['doc'] + markdown: Editor['markdown'] + serialize: NonNullable['serialize'] | undefined + blocks: readonly RichMarkdownCommentBlock[] +} + +// Keep only the current document per editor; scrolling changes geometry, not source lines. +const blocksByEditor = new WeakMap() + +export function getRichMarkdownCommentBlocks(editor: Editor): readonly RichMarkdownCommentBlock[] { + const doc = editor.state.doc + const markdown = editor.markdown + const serialize = markdown?.serialize + const cached = blocksByEditor.get(editor) + if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) { + return cached.blocks + } + + const blocks = buildRichMarkdownCommentBlocks(editor) + blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) + return blocks +} diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts b/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts new file mode 100644 index 00000000000..add8be94d44 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { NodeSelection } from '@tiptap/pm/state' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownDocLink } from './rich-markdown-doc-link' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +const editors: Editor[] = [] + +function docLinkEditor(): Editor { + const editor = new Editor({ + extensions: [StarterKit, createMarkdownDocLink(createRichMarkdownEditorCodec().transport)], + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'before' }, + { type: 'markdownDocLink', attrs: { target: 'Guide' } }, + { type: 'text', text: 'after' } + ] + } + ] + } + }) + editors.push(editor) + return editor +} + +function handleKey(editor: Editor, key: string, modifiers: KeyboardEventInit = {}): boolean { + const event = new KeyboardEvent('keydown', { key, ...modifiers }) + let handled = false + editor.view.someProp('handleKeyDown', (handler) => { + if (!handler(editor.view, event)) { + return false + } + handled = true + return true + }) + return handled +} + +afterEach(() => editors.splice(0).forEach((editor) => editor.destroy())) + +describe('document link arrow navigation', () => { + it.each([ + { key: 'ArrowLeft', from: 8, to: 13 }, + { key: 'ArrowLeft', from: 13, to: 8 }, + { key: 'ArrowRight', from: 7, to: 13 }, + { key: 'ArrowRight', from: 13, to: 7 } + ])('leaves $key selection $from..$to for native collapse', ({ key, from, to }) => { + const editor = docLinkEditor() + editor.commands.setTextSelection({ from, to }) + const before = editor.state.doc + const selection = editor.state.selection + expect(handleKey(editor, key)).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + expect(editor.state.selection.eq(selection)).toBe(true) + }) + + it.each([ + { key: 'ArrowLeft', position: 8, expectedCaret: 14 }, + { key: 'ArrowRight', position: 7, expectedCaret: 9 } + ])( + 'opens an adjacent link for editing with $key at an empty caret', + ({ key, position, expectedCaret }) => { + const editor = docLinkEditor() + editor.commands.setTextSelection(position) + expect(handleKey(editor, key)).toBe(true) + expect(editor.state.doc.textContent).toBe('before[[Guide]]after') + expect(editor.state.selection.from).toBe(expectedCaret) + expect(editor.state.selection.empty).toBe(true) + } + ) + + it.each([{ shiftKey: true }, { altKey: true }, { metaKey: true }, { ctrlKey: true }])( + 'preserves modified arrow handling: %j', + (modifiers) => { + const editor = docLinkEditor() + editor.commands.setTextSelection(8) + const before = editor.state.doc + expect(handleKey(editor, 'ArrowLeft', modifiers)).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + } + ) + + it('preserves node selection and keys away from a link', () => { + const editor = docLinkEditor() + editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 7))) + const before = editor.state.doc + expect(handleKey(editor, 'ArrowLeft')).toBe(false) + editor.commands.setTextSelection(1) + expect(handleKey(editor, 'ArrowRight')).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.ts index 1ae68d44bfe..4b26e88ac91 100644 --- a/src/renderer/src/components/editor/rich-markdown-doc-link.ts +++ b/src/renderer/src/components/editor/rich-markdown-doc-link.ts @@ -244,7 +244,7 @@ export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) { return false } const { state } = view - if (!(state.selection instanceof TextSelection)) { + if (!(state.selection instanceof TextSelection) || !state.selection.empty) { return false } const { $from } = state.selection diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts index 90bd170a5c1..f466a38b321 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts @@ -1,6 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' import type { Editor } from '@tiptap/react' -import type { JSONContent } from '@tiptap/core' import type { DiffComment } from '../../../../shared/diff-comment-types' import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight' import { @@ -10,7 +9,14 @@ import { import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges' import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map' -import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' +import { + getRichMarkdownCommentBlocks, + type RichMarkdownCommentBlock +} from './rich-markdown-comment-blocks' +export { + buildRichMarkdownCommentBlocks, + type RichMarkdownCommentBlock +} from './rich-markdown-comment-blocks' export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24 @@ -22,14 +28,6 @@ const RICH_MARKDOWN_ANNOTATION_POPOVER_WIDTH_PX = 420 const RICH_MARKDOWN_ANNOTATION_POPOVER_RIGHT_OFFSET_PX = 24 const RICH_MARKDOWN_ANNOTATION_POPOVER_MIN_HEIGHT_PX = 220 -export type RichMarkdownCommentBlock = { - key: string - startLine: number - endLine: number - from: number - to: number -} - export type RichMarkdownComposerState = { lineNumber: number startLine?: number @@ -45,54 +43,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & { buttonLeft: number } -function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { - return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() -} - -export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { - const jsonContent = editor.getJSON().content ?? [] - const blocks: RichMarkdownCommentBlock[] = [] - let nextLine = 1 - let previousNodeJson: JSONContent | null = null - let previousNodeLineCount = 0 - - editor.state.doc.forEach((node, nodeOffset, index) => { - const nodeJson = jsonContent[index] - if (!nodeJson) { - return - } - const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) - const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) - if (previousNodeJson) { - const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) - const separatorLineCount = Math.max( - 0, - countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount - ) - nextLine += separatorLineCount - } - const startLine = nextLine - const endLine = Math.max(startLine, startLine + nodeLineCount - 1) - const from = nodeOffset + 1 - blocks.push({ - key: `${index}:${startLine}-${endLine}`, - startLine, - endLine, - from, - to: from + Math.max(0, node.nodeSize - 1) - }) - nextLine = endLine + 1 - previousNodeJson = nodeJson - previousNodeLineCount = nodeLineCount - }) - - if (blocks.length === 0) { - blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) - } - - return blocks -} - export function clampRichMarkdownAnnotationTarget( editor: Editor, target: RichMarkdownAnnotationTarget @@ -122,8 +72,7 @@ export function getRichMarkdownAnnotationHighlightRanges( if (comments.length === 0) { return [] } - // Why once: block resolution re-serializes the doc; per comment it was O(n*doc). - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) return comments.flatMap((comment) => getRichMarkdownAnnotationHighlightRangesForComment( editor, @@ -138,10 +87,9 @@ export function getRichMarkdownAnnotationHighlightRangesForComment( editor: Editor, comment: DiffComment, markdownSourceLineOffset: number, - // Why optional: callers looping over comments pass one shared build. prebuiltBlocks?: readonly RichMarkdownCommentBlock[] ): RichMarkdownAnnotationHighlightRange[] { - const blocks = prebuiltBlocks ?? buildRichMarkdownCommentBlocks(editor) + const blocks = prebuiltBlocks ?? getRichMarkdownCommentBlocks(editor) const selectedText = comment.selectedText?.trim() if (!selectedText) { return [] @@ -173,7 +121,7 @@ export function getRichMarkdownCommentAtPos( if (comments.length === 0) { return null } - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) return ( comments.find((comment) => getRichMarkdownAnnotationHighlightRangesForComment( @@ -216,7 +164,7 @@ export function getRichMarkdownCommentAnchorTop( } function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerState { - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) const { from, to, empty } = editor.state.selection const selectedBlocks = empty ? blocks.filter((block) => block.from <= from && from <= block.to) diff --git a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts index 4fdc12a51b9..b7cc8bc63e2 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts @@ -1,7 +1,7 @@ import type { Editor } from '@tiptap/react' import type { DiffComment } from '../../../../shared/diff-comment-types' import { getRichMarkdownCommentAnchorTop } from './rich-markdown-review-annotations' -import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks' +import { getRichMarkdownCommentBlocks } from './rich-markdown-comment-blocks' import { stackRichMarkdownReviewNotePositions, type RichMarkdownReviewNotePosition @@ -21,7 +21,7 @@ export function measureRichMarkdownReviewNotePositions({ markdownSourceLineOffset }: MeasureRichMarkdownReviewNotePositionsOptions): RichMarkdownReviewNotePosition[] { const containerRect = container.getBoundingClientRect() - const blocks = getRichMarkdownReviewRailBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) const nextPositions = markdownComments .map((comment): RichMarkdownReviewNotePosition | null => { const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset) diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts index 7090769f71f..d64fcc667b8 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts @@ -48,7 +48,8 @@ function measureBaseline({ block, containerRect, container.scrollTop, - markdownSourceLineOffset + markdownSourceLineOffset, + buildRichMarkdownCommentBlocks(editor) ) return top === null ? null : { comment, top } }) diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts index 35dd1bb9eb5..9cb8bf0b64d 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts @@ -1,31 +1 @@ -import type { Editor } from '@tiptap/core' -import { - buildRichMarkdownCommentBlocks, - type RichMarkdownCommentBlock -} from './rich-markdown-review-annotations' - -type ReviewRailBlocks = { - doc: Editor['state']['doc'] - markdown: Editor['markdown'] - serialize: NonNullable['serialize'] | undefined - blocks: readonly RichMarkdownCommentBlock[] -} - -// Keep only the current document per editor; scrolling changes geometry, not source lines. -const blocksByEditor = new WeakMap() - -export function getRichMarkdownReviewRailBlocks( - editor: Editor -): readonly RichMarkdownCommentBlock[] { - const doc = editor.state.doc - const markdown = editor.markdown - const serialize = markdown?.serialize - const cached = blocksByEditor.get(editor) - if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) { - return cached.blocks - } - - const blocks = buildRichMarkdownCommentBlocks(editor) - blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) - return blocks -} +export { getRichMarkdownCommentBlocks as getRichMarkdownReviewRailBlocks } from './rich-markdown-comment-blocks' diff --git a/src/renderer/src/components/editor/rich-markdown-search-focus.adversarial.test.ts b/src/renderer/src/components/editor/rich-markdown-search-focus.adversarial.test.ts new file mode 100644 index 00000000000..4265cacb0fb --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-search-focus.adversarial.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { TableKit } from '@tiptap/extension-table' +import { CellSelection } from '@tiptap/pm/tables' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus' + +function findTextPosition(editor: Editor, text: string): number { + let position: number | null = null + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === text) { + position = pos + return false + } + return true + }) + if (position === null) { + throw new Error(`Expected editor text: ${text}`) + } + return position +} + +function createSurface(content: string) { + const root = document.createElement('div') + root.className = 'rich-markdown-editor-shell' + document.body.append(root) + const editor = new Editor({ extensions: [StarterKit, TableKit], content }) + root.append(editor.view.dom) + const search = document.createElement('div') + search.className = 'rich-markdown-search' + const input = document.createElement('input') + search.append(input) + root.append(search) + root.addEventListener('mousedown', (event) => { + if (event instanceof MouseEvent) { + focusRichMarkdownEditorFromSearch(event, editor.view) + } + }) + return { editor, input } +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + document.body.replaceChildren() +}) + +describe('Find focus with editor-owned selection', () => { + it('restores the remembered caret before Shift click when Find owns the native selection', () => { + vi.useFakeTimers() + const { editor, input } = createSurface('

first

lower caret

') + const caret = findTextPosition(editor, 'lower caret') + 6 + const updates = vi.fn() + editor.on('update', updates) + try { + editor.commands.setTextSelection(caret) + input.focus() + document.getSelection()?.removeAllRanges() + const paragraph = editor.view.dom.querySelector('p:last-child') + const text = paragraph?.firstChild + if (!paragraph || !text) { + throw new Error('Expected lower paragraph text') + } + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: caret, inside: -1 }) + const originalDoc = editor.state.doc + const selection = editor.state.selection + const event = new MouseEvent('mousedown', { + button: 0, + shiftKey: true, + bubbles: true, + cancelable: true + }) + + paragraph.dispatchEvent(event) + + expect(document.activeElement).toBe(editor.view.dom) + expect(document.getSelection()?.anchorNode).toBe(text) + expect(document.getSelection()?.anchorOffset).toBe(6) + expect(document.getSelection()?.focusOffset).toBe(6) + expect(event.defaultPrevented).toBe(false) + + vi.advanceTimersByTime(20) + + expect(editor.state.selection).toBe(selection) + expect(editor.state.doc).toBe(originalDoc) + expect(updates).not.toHaveBeenCalled() + } finally { + editor.destroy() + } + }) + + it('focuses a handled cross-cell selection and preserves its cells without marking content dirty', () => { + vi.useFakeTimers() + const { editor, input } = createSurface( + '
firstsecond
' + ) + const updates = vi.fn() + try { + editor.commands.setTextSelection(findTextPosition(editor, 'first')) + editor.on('update', updates) + input.focus() + const secondPos = findTextPosition(editor, 'second') + vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: secondPos, inside: secondPos }) + const second = editor.view.dom.querySelector('td:last-child p') + if (!second) { + throw new Error('Expected second table cell') + } + const originalDoc = editor.state.doc + const event = new MouseEvent('mousedown', { + button: 0, + shiftKey: true, + bubbles: true, + cancelable: true + }) + + second.dispatchEvent(event) + + const selection = editor.state.selection + expect(selection).toBeInstanceOf(CellSelection) + expect(event.defaultPrevented).toBe(true) + expect(document.activeElement).toBe(editor.view.dom) + expect(editor.view.dom.querySelectorAll('.selectedCell')).toHaveLength(2) + expect(editor.view.dom.classList.contains('ProseMirror-hideselection')).toBe(true) + expect(document.getSelection()?.rangeCount).toBe(1) + + vi.advanceTimersByTime(20) + + expect(editor.state.selection).toBe(selection) + expect(editor.state.doc).toBe(originalDoc) + expect(updates).not.toHaveBeenCalled() + expect(editor.view.dom.querySelectorAll('.selectedCell')).toHaveLength(2) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-search-focus.test.ts b/src/renderer/src/components/editor/rich-markdown-search-focus.test.ts new file mode 100644 index 00000000000..d158f4466c9 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-search-focus.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment happy-dom +import { Schema } from '@tiptap/pm/model' +import { EditorState } from '@tiptap/pm/state' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus' + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'text*' }, + text: {} + } +}) + +function createSurface() { + const root = document.createElement('div') + root.className = 'rich-markdown-editor-shell' + const editorDom = document.createElement('div') + editorDom.contentEditable = 'true' + editorDom.tabIndex = -1 + const paragraph = document.createElement('p') + paragraph.textContent = 'First editable paragraph' + editorDom.append(paragraph) + const search = document.createElement('div') + search.className = 'rich-markdown-search' + const findInput = document.createElement('input') + const replaceInput = document.createElement('input') + search.append(findInput, replaceInput) + root.append(editorDom, search) + document.body.append(root) + const focus = vi.spyOn(editorDom, 'focus') + const viewFocus = vi.fn(() => editorDom.focus({ preventScroll: true })) + const view = { dom: editorDom, focus: viewFocus, state: EditorState.create({ schema }) } + root.addEventListener('mousedown', (event) => { + if (event instanceof MouseEvent) { + focusRichMarkdownEditorFromSearch(event, view) + } + }) + return { root, editorDom, paragraph, findInput, replaceInput, focus, viewFocus } +} + +afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() +}) + +describe('rich markdown search focus handoff', () => { + it.each(['find', 'replace'] as const)( + 'returns keyboard focus from %s without preventing native selection or requesting scroll', + (field) => { + const { paragraph, editorDom, findInput, replaceInput, focus, viewFocus } = createSurface() + const input = field === 'find' ? findInput : replaceInput + input.focus() + const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }) + + paragraph.dispatchEvent(event) + + expect(document.activeElement).toBe(editorDom) + expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true }) + expect(viewFocus).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + } + ) + + it('restores the editor selection before native Shift click handling', () => { + const { paragraph, editorDom, findInput, viewFocus } = createSurface() + findInput.focus() + const event = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + button: 0, + shiftKey: true + }) + + paragraph.dispatchEvent(event) + + expect(document.activeElement).toBe(editorDom) + expect(viewFocus).toHaveBeenCalledExactlyOnceWith() + expect(event.defaultPrevented).toBe(false) + }) + + it.each(['button', 'input', 'textarea', 'select', 'noneditable', 'task-label'])( + 'leaves embedded %s controls in charge of focus', + (kind) => { + const { editorDom, findInput, focus } = createSurface() + const control = document.createElement( + kind === 'noneditable' ? 'div' : kind === 'task-label' ? 'label' : kind + ) + if (kind === 'noneditable' || kind === 'task-label') { + control.setAttribute('contenteditable', 'false') + } + const child = document.createElement('span') + control.append(child) + editorDom.append(control) + findInput.focus() + + child.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + + expect(document.activeElement).toBe(findInput) + expect(focus).not.toHaveBeenCalled() + } + ) + + it('does not steal focus from a different editor find widget or unrelated field', () => { + const first = createSurface() + const second = createSurface() + const unrelated = document.createElement('input') + document.body.append(unrelated) + + for (const input of [second.findInput, unrelated]) { + input.focus() + first.paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + expect(document.activeElement).toBe(input) + } + + expect(first.focus).not.toHaveBeenCalled() + }) + + it('leaves ordinary editor clicks and blank surface clicks untouched', () => { + const { root, editorDom, paragraph, findInput, focus } = createSurface() + editorDom.focus() + focus.mockClear() + paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + findInput.focus() + root.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + + expect(focus).not.toHaveBeenCalled() + expect(document.activeElement).toBe(findInput) + }) + + it('leaves canceled and non-primary presses untouched', () => { + const { paragraph, findInput, focus } = createSurface() + findInput.focus() + const canceled = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }) + canceled.preventDefault() + + for (const event of [ + canceled, + new MouseEvent('mousedown', { bubbles: true, button: 1 }), + new MouseEvent('mousedown', { bubbles: true, button: 2 }) + ]) { + paragraph.dispatchEvent(event) + } + + expect(focus).not.toHaveBeenCalled() + expect(document.activeElement).toBe(findInput) + }) + + it('allows editable content nested inside an unrelated noneditable ancestor', () => { + const { root, paragraph, findInput, focus } = createSurface() + root.setAttribute('contenteditable', 'false') + findInput.focus() + + paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + + expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true }) + }) + + it('does nothing when the editor has not mounted', () => { + focusRichMarkdownEditorFromSearch(new MouseEvent('mousedown', { button: 0 }), null) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-search-focus.ts b/src/renderer/src/components/editor/rich-markdown-search-focus.ts new file mode 100644 index 00000000000..8ef1e0f50c8 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-search-focus.ts @@ -0,0 +1,44 @@ +import type { EditorView } from '@tiptap/pm/view' +import { CellSelection } from '@tiptap/pm/tables' + +export function focusRichMarkdownEditorFromSearch( + event: MouseEvent, + view: Pick | null +): void { + if ( + !view || + event.button !== 0 || + (event.defaultPrevented && !(view.state.selection instanceof CellSelection)) + ) { + return + } + + const editorDom = view.dom + const target = event.target + if (!(target instanceof Element) || !editorDom.contains(target)) { + return + } + + const root = editorDom.closest('.rich-markdown-editor-shell') + const activeElement = editorDom.ownerDocument.activeElement + if ( + !root || + !activeElement?.closest('.rich-markdown-search') || + activeElement.closest('.rich-markdown-editor-shell') !== root + ) { + return + } + + const control = target.closest('button, input, textarea, select, [contenteditable="false"]') + if (control && editorDom.contains(control)) { + return + } + + if (event.shiftKey || event.defaultPrevented) { + // Shift extends the current selection; handled cell selection has no browser default. + view.focus() + } else { + // Native focus preserves the browser's upcoming click or drag selection. + editorDom.focus({ preventScroll: true }) + } +} diff --git a/src/renderer/src/components/editor/use-markdown-preview-foundation.ts b/src/renderer/src/components/editor/use-markdown-preview-foundation.ts index 623eefb4c6f..48631ce6897 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-foundation.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-foundation.ts @@ -90,6 +90,9 @@ export function useMarkdownPreviewFoundation({ const [activeReviewCommentId, setActiveReviewCommentId] = useState(null) const [attentionReviewCommentId, setAttentionReviewCommentId] = useState(null) const attentionReviewCommentTimeoutRef = useRef(null) + const pendingReviewActionFrameIdsRef = useRef([]) + const pendingReviewActionTimeoutIdsRef = useRef([]) + const reviewActionFrameGenerationRef = useRef(0) const markdownReviewNotes = useMemo( () => sortMarkdownReviewNotes(markdownComments as MarkdownReviewNote[]), [markdownComments] @@ -137,6 +140,9 @@ export function useMarkdownPreviewFoundation({ attentionReviewCommentId, setAttentionReviewCommentId, attentionReviewCommentTimeoutRef, + pendingReviewActionFrameIdsRef, + pendingReviewActionTimeoutIdsRef, + reviewActionFrameGenerationRef, markdownReviewNotes, unsentMarkdownReviewScope, canShowReviewTools diff --git a/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts b/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts index 37cfaa2884f..b2ba13f41be 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts @@ -3,6 +3,7 @@ import { copyMarkdownReviewNotesForAgent } from '@/lib/markdown-review-note-copy import type { MarkdownReviewNote } from '@/lib/markdown-review-notes' import type { DiffComment } from '../../../../shared/diff-comment-types' import type { MarkdownPreviewBlockRange } from './markdown-preview-types' +import { requestMarkdownPreviewEditorRevealFrame } from './markdown-preview-editor-reveal' import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation' import type { MarkdownPreviewViewport } from './use-markdown-preview-viewport' @@ -32,6 +33,9 @@ export function useMarkdownPreviewReviewActions({ setCopiedReviewNoteId, copiedReviewNoteResetTimerRef, attentionReviewCommentTimeoutRef, + pendingReviewActionFrameIdsRef, + pendingReviewActionTimeoutIdsRef, + reviewActionFrameGenerationRef, setAttentionReviewCommentId, setActiveReviewCommentId, markdownComments, @@ -102,20 +106,52 @@ export function useMarkdownPreviewReviewActions({ ) const pulseRenderedMarkdownReviewNote = useCallback( - (commentId: string): void => { + (commentId: string, isCurrent: () => boolean): void => { if (attentionReviewCommentTimeoutRef.current !== null) { - window.clearTimeout(attentionReviewCommentTimeoutRef.current) + const timeout = attentionReviewCommentTimeoutRef.current + window.clearTimeout(timeout) + pendingReviewActionTimeoutIdsRef.current = pendingReviewActionTimeoutIdsRef.current.filter( + (pending) => pending !== timeout + ) } setAttentionReviewCommentId(null) - window.requestAnimationFrame(() => { + if (!isCurrent()) { + return + } + requestMarkdownPreviewEditorRevealFrame(pendingReviewActionFrameIdsRef, () => { + if (!isCurrent()) { + return + } setAttentionReviewCommentId(commentId) - attentionReviewCommentTimeoutRef.current = window.setTimeout(() => { + if (!isCurrent()) { + return + } + let completed = false + let timeout: number | undefined + timeout = window.setTimeout(() => { + completed = true + if (!isCurrent()) { + return + } + pendingReviewActionTimeoutIdsRef.current = + pendingReviewActionTimeoutIdsRef.current.filter((pending) => pending !== timeout) setAttentionReviewCommentId(null) - attentionReviewCommentTimeoutRef.current = null + if (isCurrent()) { + attentionReviewCommentTimeoutRef.current = null + } }, 900) + if (!completed) { + attentionReviewCommentTimeoutRef.current = timeout + pendingReviewActionTimeoutIdsRef.current.push(timeout) + } }) }, - [attentionReviewCommentTimeoutRef, setAttentionReviewCommentId] + [ + attentionReviewCommentTimeoutRef, + pendingReviewActionFrameIdsRef, + pendingReviewActionTimeoutIdsRef, + setAttentionReviewCommentId + ] ) const findRenderedMarkdownReviewNoteCard = useCallback( @@ -135,9 +171,24 @@ export function useMarkdownPreviewReviewActions({ const scrollRenderedMarkdownReviewNoteIntoView = useCallback( (comment: DiffComment): void => { + const generation = reviewActionFrameGenerationRef.current + const isCurrent = (): boolean => + reviewNotesCopyMountedRef.current && reviewActionFrameGenerationRef.current === generation + if (!isCurrent()) { + return + } setActiveReviewCommentId(comment.id) - pulseRenderedMarkdownReviewNote(comment.id) - window.requestAnimationFrame(() => { + if (!isCurrent()) { + return + } + pulseRenderedMarkdownReviewNote(comment.id, isCurrent) + if (!isCurrent()) { + return + } + requestMarkdownPreviewEditorRevealFrame(pendingReviewActionFrameIdsRef, () => { + if (!isCurrent()) { + return + } findRenderedMarkdownReviewNoteCard(comment.id)?.scrollIntoView({ behavior: 'smooth', block: 'center', @@ -145,7 +196,14 @@ export function useMarkdownPreviewReviewActions({ }) }) }, - [findRenderedMarkdownReviewNoteCard, pulseRenderedMarkdownReviewNote, setActiveReviewCommentId] + [ + findRenderedMarkdownReviewNoteCard, + pendingReviewActionFrameIdsRef, + pulseRenderedMarkdownReviewNote, + reviewActionFrameGenerationRef, + reviewNotesCopyMountedRef, + setActiveReviewCommentId + ] ) const scrollToReviewNote = useCallback( diff --git a/src/renderer/src/components/editor/use-markdown-preview-viewport.ts b/src/renderer/src/components/editor/use-markdown-preview-viewport.ts index e4e88db2a94..f7f95a3d9ab 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-viewport.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-viewport.ts @@ -8,19 +8,12 @@ import { getMarkdownPreviewAnchorScrollTop } from './markdown-preview-anchor-navigation' import { cancelMarkdownPreviewEditorRevealFrames } from './markdown-preview-editor-reveal' +import { clearMarkdownPreviewReviewTimers } from './markdown-preview-review-timer-cleanup' import { isMarkdownPreviewFindShortcut } from './markdown-preview-search' import { useMarkdownPreviewDomSearch } from './use-markdown-preview-dom-search' import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation' import { useMarkdownPreviewScrollViewport } from './use-markdown-preview-scroll-viewport' -function clearMarkdownPreviewTimeout(timeoutRef: MutableRefObject): void { - if (timeoutRef.current === null) { - return - } - window.clearTimeout(timeoutRef.current) - timeoutRef.current = null -} - export function useMarkdownPreviewViewport({ foundation, scrollCacheKey, @@ -58,7 +51,10 @@ export function useMarkdownPreviewViewport({ reviewNotesCopiedResetTimerRef, copiedReviewNoteResetTimerRef, reviewNotesCopyMountedRef, - attentionReviewCommentTimeoutRef + attentionReviewCommentTimeoutRef, + pendingReviewActionFrameIdsRef, + pendingReviewActionTimeoutIdsRef, + reviewActionFrameGenerationRef } = foundation useMarkdownPreviewScrollViewport({ @@ -114,15 +110,24 @@ export function useMarkdownPreviewViewport({ }, [copiedReviewNoteResetTimerRef]) const cleanupPreviewSurfaceTimers = useCallback((): void => { + reviewActionFrameGenerationRef.current += 1 + const reviewFrames = pendingReviewActionFrameIdsRef.current + pendingReviewActionFrameIdsRef.current = [] + const reviewTimeouts = pendingReviewActionTimeoutIdsRef.current + pendingReviewActionTimeoutIdsRef.current = [] cancelMarkdownPreviewEditorRevealFrames(pendingEditorRevealFrameIdsRef) - clearMarkdownPreviewTimeout(attentionReviewCommentTimeoutRef) + clearMarkdownPreviewReviewTimers(attentionReviewCommentTimeoutRef, reviewTimeouts) clearReviewNotesCopiedResetTimer() clearCopiedReviewNoteResetTimer() + cancelMarkdownPreviewEditorRevealFrames({ current: reviewFrames }) }, [ attentionReviewCommentTimeoutRef, clearCopiedReviewNoteResetTimer, clearReviewNotesCopiedResetTimer, - pendingEditorRevealFrameIdsRef + pendingEditorRevealFrameIdsRef, + pendingReviewActionFrameIdsRef, + pendingReviewActionTimeoutIdsRef, + reviewActionFrameGenerationRef ]) const setRootRef = useCallback( diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.adversarial.test.tsx b/src/renderer/src/components/editor/useRichMarkdownSearch.adversarial.test.tsx new file mode 100644 index 00000000000..8eb7f376d47 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownSearch.adversarial.test.tsx @@ -0,0 +1,312 @@ +// @vitest-environment happy-dom +import { StrictMode, useLayoutEffect, type ReactNode } from 'react' +import { act, renderHook } from '@testing-library/react' +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useRichMarkdownSearch } from './useRichMarkdownSearch' + +vi.mock('@/store', () => ({ + useAppStore: (select: (state: unknown) => unknown) => select({ keybindings: {} }) +})) + +const cleanups: (() => void)[] = [] + +function mountSearch(content = '

beta beta

Edit here

', strict = false) { + vi.useFakeTimers() + const editor = new Editor({ extensions: [StarterKit], content }) + const root = document.createElement('div') + root.className = 'rich-markdown-editor-shell' + const searchBar = document.createElement('div') + searchBar.className = 'rich-markdown-search' + const input = document.createElement('input') + const otherControl = document.createElement('input') + searchBar.append(input) + root.append(editor.view.dom, searchBar) + document.body.append(root, otherControl) + const scrollContainer = document.createElement('div') + const rootRef = { current: root } + const scrollContainerRef = { current: scrollContainer } + const beforePassiveRef: { current: (() => void) | null } = { current: null } + const scrollTo = vi.spyOn(scrollContainer, 'scrollTo') + vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ top: 20, bottom: 40, left: 0, right: 0 }) + const initialProps: { currentEditor: Editor | null } = { currentEditor: editor } + const hook = renderHook( + ({ currentEditor }: { currentEditor: Editor | null }) => { + const result = useRichMarkdownSearch({ editor: currentEditor, rootRef, scrollContainerRef }) + result.searchState.searchInputRef.current = input + useLayoutEffect(() => { + const callback = beforePassiveRef.current + beforePassiveRef.current = null + callback?.() + }) + return result + }, + { + initialProps, + wrapper: strict + ? ({ children }: { children: ReactNode }) => {children} + : undefined + } + ) + cleanups.push(() => { + hook.unmount() + editor.destroy() + root.remove() + otherControl.remove() + }) + act(() => hook.result.current.openSearch()) + const query = (value: string) => { + act(() => hook.result.current.searchActions.setSearchQuery(value)) + act(() => vi.advanceTimersByTime(150)) + } + const selectedText = () => + editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to) + const documentCaret = () => { + act(() => { + editor.view.dom.focus({ preventScroll: true }) + editor.commands.setTextSelection(editor.state.doc.content.size - 1) + }) + } + return { + editor, + root, + hook, + input, + otherControl, + scrollTo, + query, + selectedText, + documentCaret, + beforePassiveRef + } +} + +afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()?.() + } + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('rich Markdown search adversarial interactions', () => { + it('does not revive a delayed search after a document click and another control focus', () => { + const { editor, hook, otherControl, query, documentCaret, scrollTo } = mountSearch() + query('beta') + act(() => hook.result.current.searchActions.setSearchQuery('Edit')) + documentCaret() + const selection = editor.state.selection + act(() => otherControl.focus()) + scrollTo.mockClear() + act(() => vi.advanceTimersByTime(150)) + expect(editor.state.selection.eq(selection)).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + expect(document.activeElement).toBe(otherControl) + }) + + it('continues typing at the document caret through case and whole-word changes', () => { + const { editor, hook, query, documentCaret, scrollTo } = mountSearch() + query('beta') + documentCaret() + scrollTo.mockClear() + act(() => hook.result.current.searchActions.toggleMatchCase()) + act(() => hook.result.current.searchActions.toggleWholeWord()) + act(() => editor.commands.insertContent('XY')) + expect(editor.getText()).toBe('beta beta\n\nEdit hereXY') + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('lets explicit search navigation select the only match after document editing', () => { + const { editor, hook, query, documentCaret, selectedText, scrollTo } = mountSearch( + '

beta

Edit here

' + ) + query('beta') + documentCaret() + act(() => editor.commands.insertContent('X')) + scrollTo.mockClear() + act(() => hook.result.current.searchActions.moveToMatch(1)) + expect(selectedText()).toBe('beta') + expect(scrollTo).toHaveBeenCalledOnce() + expect(editor.isFocused).toBe(true) + }) + + it('advances past replacement text which still contains the search query', () => { + const { editor, hook, query, selectedText } = mountSearch() + query('beta') + act(() => hook.result.current.searchActions.setReplaceQuery('betaX')) + act(() => hook.result.current.searchActions.replaceCurrentMatch()) + expect(editor.getText()).toBe('betaX beta\n\nEdit here') + expect(selectedText()).toBe('beta') + expect(editor.state.selection.from).toBe(7) + act(() => hook.result.current.searchActions.replaceCurrentMatch()) + expect(editor.getText()).toBe('betaX betaX\n\nEdit here') + }) + + it('does not move the document caret when Replace All retains matching text', () => { + const { editor, hook, query, documentCaret, scrollTo } = mountSearch() + query('beta') + act(() => hook.result.current.searchActions.setReplaceQuery('betaX')) + documentCaret() + scrollTo.mockClear() + act(() => hook.result.current.searchActions.replaceAllMatches()) + expect(editor.getText()).toBe('betaX betaX\n\nEdit here') + expect(editor.state.selection.empty).toBe(true) + expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('clamps navigation after external edits remove the active match', () => { + const { editor, hook, query, selectedText } = mountSearch('

beta beta beta

') + query('beta') + act(() => hook.result.current.searchActions.moveToMatch(1)) + act(() => hook.result.current.searchActions.moveToMatch(1)) + act(() => editor.commands.setContent('

beta

')) + expect(hook.result.current.searchState.activeMatchIndex).toBe(0) + act(() => hook.result.current.searchActions.moveToMatch(1)) + expect(selectedText()).toBe('beta') + }) + + it('keeps decorations fresh without moving selection through batched document edits', () => { + const { editor, hook, query, documentCaret, scrollTo } = mountSearch() + query('beta') + documentCaret() + scrollTo.mockClear() + act(() => { + editor.commands.insertContent(' beta') + editor.commands.insertContent(' beta') + }) + expect(hook.result.current.searchState.matchCount).toBe(4) + expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1) + expect(editor.state.selection.empty).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('does not dispatch stale match positions when the document changes before passive effects', () => { + const { editor, hook, beforePassiveRef } = mountSearch('

beta

target

') + act(() => hook.result.current.searchActions.setSearchQuery('target')) + beforePassiveRef.current = () => editor.commands.setContent('

x

') + expect(() => act(() => vi.advanceTimersByTime(150))).not.toThrow() + expect(editor.getText()).toBe('x') + expect(hook.result.current.searchState.matchCount).toBe(0) + }) + + it('cancels a pending query when the input is cleared', () => { + const { editor, hook, query, scrollTo } = mountSearch() + query('beta') + const selection = editor.state.selection + scrollTo.mockClear() + act(() => hook.result.current.searchActions.setSearchQuery('Edit')) + act(() => hook.result.current.searchActions.setSearchQuery('')) + act(() => vi.advanceTimersByTime(150)) + expect(hook.result.current.searchState.matchCount).toBe(0) + expect(editor.state.selection.eq(selection)).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('uses refreshed positions when the document changes before search navigation commits', () => { + const { editor, hook, beforePassiveRef, selectedText, scrollTo } = mountSearch( + '

beta

target

' + ) + act(() => hook.result.current.searchActions.setSearchQuery('target')) + beforePassiveRef.current = () => editor.commands.setContent('

prefix prefix target

') + scrollTo.mockClear() + act(() => vi.advanceTimersByTime(150)) + expect(hook.result.current.searchState.matchCount).toBe(1) + expect(selectedText()).toBe('target') + expect(editor.state.selection.from).toBe(15) + expect(scrollTo).toHaveBeenCalledOnce() + }) + + it('moves Next relative to the clamped active match after several matches disappear', () => { + const { editor, hook, query } = mountSearch('

beta beta beta beta beta

') + query('beta') + for (let index = 0; index < 4; index++) { + act(() => hook.result.current.searchActions.moveToMatch(1)) + } + expect(hook.result.current.searchState.activeMatchIndex).toBe(4) + act(() => editor.commands.setContent('

beta beta beta

')) + expect(hook.result.current.searchState.activeMatchIndex).toBe(0) + act(() => hook.result.current.searchActions.moveToMatch(1)) + expect(hook.result.current.searchState.activeMatchIndex).toBe(1) + expect(editor.state.selection.from).toBe(6) + }) + + it('does not navigate to an old query when replacing during the debounce window', () => { + const { editor, hook, query, scrollTo } = mountSearch() + query('beta') + act(() => hook.result.current.searchActions.setSearchQuery('Edit')) + act(() => hook.result.current.searchActions.setReplaceQuery('changed')) + act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1)) + scrollTo.mockClear() + act(() => hook.result.current.searchActions.replaceCurrentMatch()) + expect(editor.getText()).toBe('beta beta\n\nchanged here') + expect(editor.state.selection.empty).toBe(true) + expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1) + expect(scrollTo).not.toHaveBeenCalled() + }) + + it('survives strict effect replay, null editor, and a fresh editor instance', () => { + const { editor, hook, input, query, documentCaret, scrollTo } = mountSearch(undefined, true) + query('beta') + documentCaret() + act(() => editor.commands.insertContent('X')) + act(() => input.focus()) + act(() => hook.rerender({ currentEditor: null })) + const replacement = new Editor({ extensions: [StarterKit], content: '

beta

' }) + vi.spyOn(replacement.view, 'coordsAtPos').mockReturnValue({ + top: 20, + bottom: 40, + left: 0, + right: 0 + }) + scrollTo.mockClear() + act(() => hook.rerender({ currentEditor: replacement })) + expect(hook.result.current.searchState.matchCount).toBe(1) + expect( + replacement.state.doc.textBetween( + replacement.state.selection.from, + replacement.state.selection.to + ) + ).toBe('beta') + expect(scrollTo).toHaveBeenCalledOnce() + act(() => hook.rerender({ currentEditor: null })) + replacement.destroy() + }) + + it('survives a debounce completing after its editor has been destroyed', () => { + const { editor, hook } = mountSearch() + act(() => hook.result.current.searchActions.setSearchQuery('beta')) + act(() => editor.destroy()) + expect(() => act(() => vi.advanceTimersByTime(150))).not.toThrow() + }) + + it('does not replay an old Next request into a replacement editor after focus leaves Find', () => { + const { editor, hook, root, query, documentCaret, scrollTo } = mountSearch() + query('beta') + act(() => hook.result.current.searchActions.moveToMatch(1)) + documentCaret() + act(() => hook.rerender({ currentEditor: null })) + editor.view.dom.remove() + const replacement = new Editor({ extensions: [StarterKit], content: '

beta

' }) + root.prepend(replacement.view.dom) + vi.spyOn(replacement.view, 'coordsAtPos').mockReturnValue({ + top: 20, + bottom: 40, + left: 0, + right: 0 + }) + scrollTo.mockClear() + act(() => hook.rerender({ currentEditor: replacement })) + expect(replacement.state.selection.empty).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + act(() => hook.rerender({ currentEditor: null })) + replacement.destroy() + }) + + it('survives the editor being destroyed by a selection-update listener', () => { + const { editor, query } = mountSearch() + editor.on('selectionUpdate', () => editor.destroy()) + expect(() => query('beta')).not.toThrow() + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.editing.test.tsx b/src/renderer/src/components/editor/useRichMarkdownSearch.editing.test.tsx new file mode 100644 index 00000000000..b9a8df6e89f --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownSearch.editing.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useRichMarkdownSearch } from './useRichMarkdownSearch' + +vi.mock('@/store', () => ({ + useAppStore: (select: (state: unknown) => unknown) => select({ keybindings: {} }) +})) + +function mountSearch() { + const editor = new Editor({ + extensions: [StarterKit], + content: '

beta beta

Edit here

' + }) + const scrollContainer = document.createElement('div') + const scrollTo = vi.spyOn(scrollContainer, 'scrollTo') + vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ top: 20, bottom: 40, left: 0, right: 0 }) + const root = document.createElement('div') + root.className = 'rich-markdown-editor-shell' + const search = document.createElement('div') + search.className = 'rich-markdown-search' + const input = document.createElement('input') + search.append(input) + root.append(editor.view.dom) + root.append(search) + document.body.append(root) + const hook = renderHook(() => { + const result = useRichMarkdownSearch({ + editor, + rootRef: { current: root }, + scrollContainerRef: { current: scrollContainer } + }) + result.searchState.searchInputRef.current = input + return result + }) + act(() => hook.result.current.openSearch()) + act(() => hook.result.current.searchActions.setSearchQuery('beta')) + act(() => vi.advanceTimersByTime(150)) + const dispose = () => { + hook.unmount() + editor.destroy() + root.remove() + } + return { editor, hook, scrollTo, dispose } +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('rich markdown editing with Find open', () => { + it('updates highlights without moving the typing caret or scrolling to a match', () => { + vi.useFakeTimers() + const { editor, hook, scrollTo, dispose } = mountSearch() + try { + const insertion = editor.state.doc.content.size - 1 + act(() => editor.commands.setTextSelection(insertion)) + scrollTo.mockClear() + act(() => editor.commands.insertContent('X')) + act(() => editor.commands.insertContent('Y')) + expect(editor.getText()).toBe('beta beta\n\nEdit hereXY') + expect(editor.state.selection.from).toBe(insertion + 2) + expect(editor.state.selection.empty).toBe(true) + expect(hook.result.current.searchState.matchCount).toBe(2) + expect(hook.result.current.searchState.isSearchOpen).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + act(() => editor.commands.undo()) + expect(editor.getText()).toBe('beta beta\n\nEdit here') + expect(scrollTo).not.toHaveBeenCalled() + } finally { + dispose() + } + }) + + it('preserves selection when edits remove every match and later restore one', () => { + vi.useFakeTimers() + const { editor, hook, scrollTo, dispose } = mountSearch() + try { + scrollTo.mockClear() + act(() => editor.commands.setContent('

No results

End

')) + expect(hook.result.current.searchState.matchCount).toBe(0) + act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1)) + act(() => editor.commands.insertContent(' beta')) + expect(hook.result.current.searchState.matchCount).toBe(1) + expect(editor.state.selection.empty).toBe(true) + expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1) + expect(scrollTo).not.toHaveBeenCalled() + } finally { + dispose() + } + }) + + it('still navigates on Next with only one match, and advances after Replace', () => { + vi.useFakeTimers() + const { editor, hook, scrollTo, dispose } = mountSearch() + try { + act(() => hook.result.current.searchActions.setReplaceQuery('changed')) + act(() => hook.result.current.searchActions.replaceCurrentMatch()) + expect(editor.getText()).toBe('changed beta\n\nEdit here') + expect(hook.result.current.searchState.matchCount).toBe(1) + expect( + editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to) + ).toBe('beta') + act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1)) + scrollTo.mockClear() + act(() => hook.result.current.searchActions.moveToMatch(1)) + expect( + editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to) + ).toBe('beta') + expect(scrollTo).toHaveBeenCalledOnce() + } finally { + dispose() + } + }) + + it('does not apply delayed search navigation after focus returns to the document', () => { + vi.useFakeTimers() + const { editor, hook, scrollTo, dispose } = mountSearch() + try { + act(() => hook.result.current.searchActions.setSearchQuery('Edit')) + act(() => { + editor.view.dom.focus({ preventScroll: true }) + editor.commands.setTextSelection(editor.state.doc.content.size - 1) + }) + expect(editor.isFocused).toBe(true) + const selection = editor.state.selection + scrollTo.mockClear() + act(() => vi.advanceTimersByTime(150)) + expect(hook.result.current.searchState.matchCount).toBe(1) + expect(editor.state.selection.eq(selection)).toBe(true) + expect(scrollTo).not.toHaveBeenCalled() + } finally { + dispose() + } + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.ts b/src/renderer/src/components/editor/useRichMarkdownSearch.ts index 1a0f132e67c..bcc304c3463 100644 --- a/src/renderer/src/components/editor/useRichMarkdownSearch.ts +++ b/src/renderer/src/components/editor/useRichMarkdownSearch.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' import type { Editor } from '@tiptap/react' -import { TextSelection } from '@tiptap/pm/state' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' import { @@ -14,6 +13,7 @@ import { richMarkdownSearchPluginKey } from './rich-markdown-search' import { createRichMarkdownSearchMatchesCache } from './rich-markdown-search-matches-cache' +import { useRichMarkdownSearchHighlights } from './useRichMarkdownSearchHighlights' export function useRichMarkdownSearch({ editor, @@ -42,7 +42,8 @@ export function useRichMarkdownSearch({ const [matchCase, setMatchCase] = useState(false) const [wholeWord, setWholeWord] = useState(false) const [rawActiveMatchIndex, setRawActiveMatchIndex] = useState(-1) - const [searchRevision, setSearchRevision] = useState(0) + const [navigationRequest, setNavigationRequest] = useState({ revision: 0, selectMatch: false }) + const [, setSearchRevision] = useState(0) // Why: debouncing the query that drives match computation prevents the // expensive full-doc walk from running on every keystroke — the old // un-debounced path froze the main thread on large documents. @@ -59,24 +60,24 @@ export function useRichMarkdownSearch({ const searchRequestQuery = isMarkdownPreviewSearchQueryTooLarge(debouncedQuery) ? '' : debouncedQuery + const searchDocument = editor && !editor.isDestroyed ? editor.state.doc : null const matches = useMemo(() => { - if (!editor || !isSearchOpen || !searchRequestQuery) { + if (!searchDocument || !isSearchOpen || !searchRequestQuery) { return [] } - return findMatches(editor.state.doc, searchRequestQuery, { + return findMatches(searchDocument, searchRequestQuery, { matchCase, wholeWord }) - // searchRevision is bumped on ProseMirror doc edits to trigger recomputation - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editor, findMatches, isSearchOpen, searchRequestQuery, searchRevision, matchCase, wholeWord]) + }, [findMatches, isSearchOpen, searchRequestQuery, searchDocument, matchCase, wholeWord]) const matchCount = matches.length const getLiveMatches = useCallback(() => { if ( !editor || + editor.isDestroyed || !isSearchOpen || !searchQuery || isMarkdownPreviewSearchQueryTooLarge(searchQuery) @@ -175,10 +176,14 @@ export function useRichMarkdownSearch({ if (!match || liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)) { return } - // Why: removing the active match shifts the next match into the same index, - // so leaving rawActiveMatchIndex untouched advances to it after recompute. replaceRange(match.from, match.to) - }, [activeMatchIndex, getLiveMatches, replaceRange]) + // Skip matches inside the replacement, including when it still contains the query. + const replacementEnd = match.from + replaceQuery.length + const nextIndex = getLiveMatches().findIndex((candidate) => candidate.from >= replacementEnd) + setRawActiveMatchIndex(Math.max(0, nextIndex)) + setDebouncedQuery(searchQuery) + setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: true })) + }, [activeMatchIndex, getLiveMatches, replaceQuery, replaceRange, searchQuery]) const replaceAllMatches = useCallback(() => { if (!editor) { @@ -203,24 +208,25 @@ export function useRichMarkdownSearch({ } } editor.view.dispatch(tr) - }, [editor, getLiveMatches, replaceQuery]) + setDebouncedQuery(searchQuery) + setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: false })) + }, [editor, getLiveMatches, replaceQuery, searchQuery]) const moveToMatch = useCallback( (direction: 1 | -1) => { - if (matchCount === 0) { + const liveMatchCount = getLiveMatches().length + if (liveMatchCount === 0) { return } - // Why: rawActiveMatchIndex starts at -1 before the user navigates, but the - // derived activeMatchIndex is already 0 (first match shown). Using 0 as the - // base when raw is -1 ensures the first Enter press advances to match 1 - // instead of computing (-1+1)%N = 0 and leaving the effect unchanged. setRawActiveMatchIndex((currentIndex) => { - const baseIndex = Math.max(currentIndex, 0) - return (baseIndex + direction + matchCount) % matchCount + const baseIndex = currentIndex >= 0 && currentIndex < liveMatchCount ? currentIndex : 0 + return (baseIndex + direction + liveMatchCount) % liveMatchCount }) + setDebouncedQuery(searchQuery) + setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: true })) }, - [matchCount] + [getLiveMatches, searchQuery] ) const handleEditorUpdate = useCallback(() => { @@ -259,52 +265,18 @@ export function useRichMarkdownSearch({ searchInputRef.current?.select() }, [isSearchOpen]) - // Why: single effect to sync search state to ProseMirror. The old two-effect - // chain (compute matches → set state → dispatch) caused an extra render cycle - // and called findRichMarkdownSearchMatches twice per change. - useEffect(() => { - if (!editor) { - return - } - - const query = isSearchOpen ? searchRequestQuery : '' - - // Why: combining decoration meta and selection+scrollIntoView into one - // transaction avoids a split-dispatch where the first dispatch updates - // editor.state and the second dispatch's scrollIntoView can be lost - // when ProseMirror coalesces view updates. - // Why: passing pre-computed matches avoids the plugin re-walking the - // entire document — the old double-walk froze the UI on large files. - const tr = editor.state.tr - tr.setMeta(richMarkdownSearchPluginKey, { - activeIndex: activeMatchIndex, - matches, - query - }) - - const activeMatch = query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null - if (activeMatch) { - tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to)) - } - - editor.view.dispatch(tr) - - // Why: ProseMirror's tr.scrollIntoView() delegates to the view's - // scrollDOMIntoView which may fail to reach the outer flex scroll container - // (the editor element itself has min-height: 100% and no overflow). - // Reading coordsAtPos *after* the dispatch and manually scrolling the - // container mirrors the approach used by MarkdownPreview search. - if (activeMatch) { - const container = scrollContainerRef.current - if (container) { - const coords = editor.view.coordsAtPos(activeMatch.from) - const containerRect = container.getBoundingClientRect() - const relativeTop = coords.top - containerRect.top - const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2 - container.scrollTo({ top: targetScroll, behavior: 'instant' }) - } - } - }, [activeMatchIndex, searchRequestQuery, editor, isSearchOpen, matches, scrollContainerRef]) + useRichMarkdownSearchHighlights({ + activeMatchIndex, + editor, + matchCase, + matches, + navigationRequest, + query: isSearchOpen ? searchRequestQuery : '', + scrollContainerRef, + wholeWord, + rootRef, + searchDocument + }) useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { diff --git a/src/renderer/src/components/editor/useRichMarkdownSearchHighlights.ts b/src/renderer/src/components/editor/useRichMarkdownSearchHighlights.ts new file mode 100644 index 00000000000..c57c2a4e973 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownSearchHighlights.ts @@ -0,0 +1,104 @@ +import { useEffect, useRef, type RefObject } from 'react' +import type { Editor } from '@tiptap/react' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { TextSelection } from '@tiptap/pm/state' +import { richMarkdownSearchPluginKey, type RichMarkdownSearchMatch } from './rich-markdown-search' + +type SearchNavigation = { + editor: Editor + query: string + matchCase: boolean + wholeWord: boolean + navigationRequest: { revision: number; selectMatch: boolean } +} + +export function useRichMarkdownSearchHighlights({ + activeMatchIndex, + editor, + matchCase, + matches, + navigationRequest, + query, + rootRef, + searchDocument, + scrollContainerRef, + wholeWord +}: { + activeMatchIndex: number + editor: Editor | null + matchCase: boolean + matches: RichMarkdownSearchMatch[] + navigationRequest: SearchNavigation['navigationRequest'] + query: string + rootRef: RefObject + searchDocument: ProseMirrorNode | null + scrollContainerRef: RefObject + wholeWord: boolean +}): void { + const lastNavigationRef = useRef(null) + + useEffect(() => { + if (!editor || editor.isDestroyed) { + lastNavigationRef.current = null + return + } + // A newer editor transaction can arrive between render and this effect. + if (editor.state.doc !== searchDocument) { + return + } + const previous = lastNavigationRef.current + const searchChanged = + previous?.editor !== editor || + previous.query !== query || + previous.matchCase !== matchCase || + previous.wholeWord !== wholeWord + const navigationRequested = + previous?.editor === editor && previous.navigationRequest !== navigationRequest + lastNavigationRef.current = { editor, query, matchCase, wholeWord, navigationRequest } + + // Refreshing matches after an edit must preserve the user's caret and viewport. + const activeElement = rootRef.current?.ownerDocument.activeElement + const searchOwnsFocus = + activeElement && + rootRef.current?.contains(activeElement) && + activeElement.closest('.rich-markdown-search') + const shouldNavigate = navigationRequested + ? navigationRequest.selectMatch + : searchChanged && searchOwnsFocus + const activeMatch = + shouldNavigate && query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null + const tr = editor.state.tr.setMeta(richMarkdownSearchPluginKey, { + activeIndex: activeMatchIndex, + matches, + query + }) + if (activeMatch) { + tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to)) + } + editor.view.dispatch(tr) + if (editor.isDestroyed || editor.state.doc !== tr.doc) { + return + } + + // The editor's scrollIntoView does not reliably reach the outer flex viewport. + const container = scrollContainerRef.current + if (activeMatch && container) { + const coords = editor.view.coordsAtPos(activeMatch.from) + const containerRect = container.getBoundingClientRect() + const relativeTop = coords.top - containerRect.top + const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2 + container.scrollTo({ top: targetScroll, behavior: 'instant' }) + } + }, [ + activeMatchIndex, + editor, + matchCase, + matches, + navigationRequest, + query, + rootRef, + searchDocument, + scrollContainerRef, + wholeWord + ]) +} diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts index 42fba184a2e..12d6831f18c 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section-mutations.ts @@ -10,7 +10,9 @@ import { import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority' import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations' import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request-types' import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { GitHubPatchWorkItemOptions } from '@/store/github/cache-model' import { translate } from '@/i18n/i18n' import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' @@ -28,6 +30,7 @@ type GHEditMutationBase = { repoPath: string | null sourceContext?: TaskSourceContext | null projectOrigin: GitHubItemDialogProjectOrigin | undefined + issueRepo?: GitHubOwnerRepo | null run: GHEditMutationRun patchProjectRowIfNeeded: (patch: GHEditProjectRowPatch) => void onMutated: () => void @@ -43,6 +46,7 @@ export function runGHEditStateChange({ repoPath, sourceContext, projectOrigin, + issueRepo, run, onStateChange, patchWorkItem, @@ -58,7 +62,7 @@ export function runGHEditStateChange({ id: string, patch: { state: GitHubWorkItem['state'] }, repoId: string | undefined, - options: { sourceContext?: TaskSourceContext | null } + options: GitHubPatchWorkItemOptions ) => void }): void { // Why: a close reason still has to reach GitHub even when the item already reads as closed locally. @@ -76,6 +80,7 @@ export function runGHEditStateChange({ repoPath, sourceContext, projectOrigin, + issueRepo, number: itemNumber, updates: newState === 'closed' && closeAction @@ -87,22 +92,32 @@ export function runGHEditStateChange({ repoId: itemRepoId, itemId, state: newState, - sourceContext + sourceContext, + ownerRepo: projectOrigin ?? issueRepo }) onStateChange(newState) - patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext }) + patchWorkItem(itemId, { state: newState }, itemRepoId, { + sourceContext, + ownerRepo: projectOrigin ?? issueRepo + }) patchProjectRowIfNeeded({ state: newState }) }, onRevert: () => { if (authority?.revert()) { onStateChange(prevState) - patchWorkItem(itemId, { state: prevState }, itemRepoId, { sourceContext }) + patchWorkItem(itemId, { state: prevState }, itemRepoId, { + sourceContext, + ownerRepo: projectOrigin ?? issueRepo + }) patchProjectRowIfNeeded({ state: prevState }) } }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('github-tasks') - patchWorkItem(itemId, { state: newState }, itemRepoId, { sourceContext }) + patchWorkItem(itemId, { state: newState }, itemRepoId, { + sourceContext, + ownerRepo: projectOrigin ?? issueRepo + }) patchProjectRowIfNeeded({ state: newState }) onMutated() }, @@ -145,6 +160,7 @@ export function runGHEditLabelToggle({ repoPath, sourceContext, projectOrigin, + issueRepo, run, onLabelsChange, patchWorkItem, @@ -159,42 +175,13 @@ export function runGHEditLabelToggle({ id: string, patch: { labels: string[] }, repoId: string | undefined, - options: { sourceContext?: TaskSourceContext | null } + options: GitHubPatchWorkItemOptions ) => void }): void { const isAdding = !localLabels.includes(label) const prevLabels = localLabels const newLabels = isAdding ? [...prevLabels, label] : prevLabels.filter((l) => l !== label) - if (isAdding) { - void run('labels', { - mutate: () => - runIssueUpdate({ - repoId: itemRepoId, - repoPath, - sourceContext, - projectOrigin, - number: itemNumber, - updates: { addLabels: [label] } - }), - onOptimistic: () => { - onLabelsChange(newLabels) - patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: newLabels }) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onRevert: () => { - onLabelsChange(prevLabels) - patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { sourceContext }) - patchProjectRowIfNeeded({ labels: prevLabels }) - }, - onError: (err) => toast.error(err) - }) - return - } void run('labels', { mutate: () => runIssueUpdate({ @@ -202,17 +189,24 @@ export function runGHEditLabelToggle({ repoPath, sourceContext, projectOrigin, + issueRepo, number: itemNumber, - updates: { removeLabels: [label] } + updates: isAdding ? { addLabels: [label] } : { removeLabels: [label] } }), onOptimistic: () => { onLabelsChange(newLabels) - patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { sourceContext }) + patchWorkItem(itemId, { labels: newLabels }, itemRepoId, { + sourceContext, + ownerRepo: projectOrigin ?? issueRepo + }) patchProjectRowIfNeeded({ labels: newLabels }) }, onRevert: () => { onLabelsChange(prevLabels) - patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { sourceContext }) + patchWorkItem(itemId, { labels: prevLabels }, itemRepoId, { + sourceContext, + ownerRepo: projectOrigin ?? issueRepo + }) patchProjectRowIfNeeded({ labels: prevLabels }) }, onSuccess: () => { @@ -233,6 +227,7 @@ export function runGHEditAssigneeToggle({ repoPath, sourceContext, projectOrigin, + issueRepo, run, setLocalAssignees, patchProjectRowIfNeeded, @@ -260,6 +255,7 @@ export function runGHEditAssigneeToggle({ repoPath, sourceContext, projectOrigin, + issueRepo, number: itemNumber, updates: { removeAssignees: [login] } }), @@ -288,6 +284,7 @@ export function runGHEditAssigneeToggle({ repoPath, sourceContext, projectOrigin, + issueRepo, number: itemNumber, updates: { addAssignees: [login] } }), diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx new file mode 100644 index 00000000000..be5ee88f54d --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.assignee-identity.test.tsx @@ -0,0 +1,364 @@ +// @vitest-environment happy-dom + +import { act, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import { GHEditSection } from './gh-edit-section' +import { resetTaskPageGitHubMutationRegistryForTests } from '@/components/task-page-github-work-item-mutation-registry' + +const mocks = vi.hoisted(() => ({ + update: vi.fn(), + patchWorkItem: vi.fn(), + patchProjectRowContent: vi.fn(), + recordFeatureInteraction: vi.fn(), + onMutated: vi.fn() +})) + +vi.mock('@/store', () => { + const state = { + patchWorkItem: mocks.patchWorkItem, + patchProjectRowContent: mocks.patchProjectRowContent, + recordFeatureInteraction: mocks.recordFeatureInteraction, + repos: [], + settings: null + } + return { + useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), { + getState: () => state + }) + } +}) + +vi.mock('@/hooks/useIssueMetadata', async () => { + const { useImmediateMutation } = await import('@/hooks/useImmediateMutation') + return { + useImmediateMutation, + useRepoLabels: () => ({ data: [] }), + useRepoAssignees: () => ({ data: [] }) + } +}) +vi.mock('@/hooks/useGitHubSlugMetadata', () => ({ + useRepoLabelsBySlug: () => ({ data: [] }), + useRepoAssigneesBySlug: () => ({ data: [] }) +})) +vi.mock('@/components/github/github-duplicate-issue-candidates', () => ({ + useGitHubDuplicateIssueCandidates: () => [] +})) +vi.mock('@/components/github/github-work-item-edit-mutations', () => ({ + runIssueUpdate: mocks.update +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('./gh-edit-section-horizontal', () => ({ GHEditSectionHorizontal: () => null })) +vi.mock('./gh-edit-section-top-columns', () => ({ + GHEditSectionTopColumns: ({ + localAssignees, + onAssigneeToggle, + localState, + localLabels, + onStateChange, + onLabelToggle + }: { + localAssignees: string[] + onAssigneeToggle: (login: string) => void + localState: GitHubWorkItem['state'] + localLabels: string[] + onStateChange: (state: 'open' | 'closed') => void + onLabelToggle: (label: string) => void + }) => ( + <> + {localAssignees.join(',')} + {localState} + {localLabels.join(',')} + + + + + + ) +})) + +const item: GitHubWorkItem = { + id: 'issue:5', + repoId: 'registered-repo', + type: 'issue', + number: 5, + title: 'Issue five', + state: 'open', + url: 'https://github.com/upstream/widgets/issues/5', + labels: [], + updatedAt: '', + author: null +} +let root: Root +let container: HTMLDivElement + +async function render( + nextItem = item, + assignees: string[] = [], + projectOrigin?: GitHubItemDialogProjectOrigin +): Promise { + await act(async () => { + root.render( + {}} + onLabelsChange={() => {}} + onMutated={mocks.onMutated} + assignees={assignees} + onUse={() => {}} + layout="top-columns" + /> + ) + }) +} + +async function click(label = 'Assign candidate'): Promise { + const button = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + expect(button).toBeDefined() + await act(async () => button?.click()) +} + +function selected(): string | null { + return container.querySelector('[data-field="assignees"]')?.textContent ?? null +} + +let updateParentSelection: (nextItem: GitHubWorkItem) => void = () => {} + +function ParentSelection({ item }: { item: GitHubWorkItem }): React.JSX.Element { + const [localState, setLocalState] = useState(item.state) + const [localLabels, setLocalLabels] = useState(item.labels) + updateParentSelection = (nextItem) => { + setLocalState(nextItem.state) + setLocalLabels(nextItem.labels) + } + return ( + {}} + layout="top-columns" + /> + ) +} + +async function renderParent(nextItem: GitHubWorkItem): Promise { + await act(async () => { + updateParentSelection(nextItem) + root.render() + }) +} + +describe('opened issue assignee ownership', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.clearAllMocks() + resetTaskPageGitHubMutationRegistryForTests() + updateParentSelection = () => {} + mocks.update.mockResolvedValue({ ok: true }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + resetTaskPageGitHubMutationRegistryForTests() + }) + + it('retires an optimistic selection across fork and return navigation', async () => { + await render() + await click() + expect(selected()).toBe('candidate-user') + await render(item, []) + expect(selected()).toBe('candidate-user') + + await render({ ...item, url: 'https://github.com/fork/widgets/issues/5' }, ['fork-user']) + expect(selected()).toBe('fork-user') + await render(item, ['authoritative-upstream-user']) + expect(selected()).toBe('authoritative-upstream-user') + }) + + it('does not revive an edited guard when returning from a different issue number', async () => { + await render() + await click() + await render({ ...item, id: 'issue:6', number: 6 }, ['other-issue-user']) + expect(selected()).toBe('other-issue-user') + await render(item, ['authoritative-upstream-user']) + expect(selected()).toBe('authoritative-upstream-user') + }) + + it.each(['Assign candidate', 'Toggle original'])( + 'does not roll a late failed %s edit into another opened issue', + async (button) => { + let rejectUpdate: (reason: Error) => void = () => {} + mocks.update.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectUpdate = reject + }) + ) + await render(item, ['original-user']) + await click(button) + await render({ ...item, id: 'issue:6', number: 6 }, ['other-issue-user']) + expect(selected()).toBe('other-issue-user') + await act(async () => rejectUpdate(new Error('held edit failed'))) + expect(selected()).toBe('other-issue-user') + } + ) + + it('treats another GitHub host as a distinct same-number issue', async () => { + await render() + await click() + await render({ ...item, url: 'https://github.example.test/upstream/widgets/issues/5' }, []) + expect(selected()).toBe('') + }) + + it('retires the issue edit when the same legacy id is used by a pull request', async () => { + await render() + await click() + await render({ ...item, type: 'pr' }, []) + await render(item, ['authoritative-upstream-user']) + expect(selected()).toBe('authoritative-upstream-user') + }) + + it('preserves optimistic edits across canonical URL casing and trailing paths', async () => { + await render() + await click() + await render({ ...item, url: 'https://GITHUB.COM/Upstream/Widgets/issues/5#activity' }, []) + expect(selected()).toBe('candidate-user') + }) + + it('uses the Project row repository before an unrelated item URL', async () => { + const projectOrigin: GitHubItemDialogProjectOrigin = { + owner: 'upstream', + repo: 'widgets', + number: 5, + type: 'issue', + projectId: 'project', + projectItemId: 'row', + cacheKey: 'project-cache' + } + await render(item, [], projectOrigin) + await click() + await render( + { ...item, url: 'https://github.com/unrelated/widgets/issues/5' }, + [], + projectOrigin + ) + expect(selected()).toBe('candidate-user') + await render(item, ['fork-user'], { ...projectOrigin, owner: 'fork' }) + expect(selected()).toBe('fork-user') + }) + + it('still rolls back a failed edit while its issue remains open', async () => { + mocks.update.mockRejectedValue(new Error('edit failed')) + await render(item, ['original-user']) + await click() + expect(selected()).toBe('original-user') + await render(item, ['refetched-user']) + expect(selected()).toBe('refetched-user') + }) + + it('keeps a late Project-row rollback scoped to the captured row', async () => { + const projectOrigin: GitHubItemDialogProjectOrigin = { + owner: 'upstream', + repo: 'widgets', + number: 5, + type: 'issue', + projectId: 'project', + projectItemId: 'upstream-row', + cacheKey: 'upstream-cache' + } + let rejectUpdate: (reason: Error) => void = () => {} + mocks.update.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectUpdate = reject + }) + ) + await render(item, ['original-user'], projectOrigin) + await click() + await render(item, ['fork-user'], { + ...projectOrigin, + owner: 'fork', + projectItemId: 'fork-row', + cacheKey: 'fork-cache' + }) + expect(selected()).toBe('fork-user') + await act(async () => rejectUpdate(new Error('held Project edit failed'))) + expect(selected()).toBe('fork-user') + expect(mocks.patchProjectRowContent).toHaveBeenLastCalledWith( + 'upstream-cache', + 'upstream-row', + { assignees: ['original-user'] } + ) + }) + + it.each([ + { button: 'Toggle label', field: 'labels', expected: 'fork-only-label' }, + { button: 'Close issue', field: 'state', expected: 'closed' } + ])( + 'a late failed $field edit cannot overwrite the new parent selection', + async ({ button, field, expected }) => { + let rejectUpdate: (reason: Error) => void = () => {} + mocks.update.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectUpdate = reject + }) + ) + await renderParent({ ...item, labels: ['upstream-original-label'] }) + await click(button) + await renderParent({ + ...item, + state: 'closed', + url: 'https://github.com/fork/widgets/issues/5', + labels: ['fork-only-label'] + }) + expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected) + await act(async () => rejectUpdate(new Error('held edit failed'))) + expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected) + expect(mocks.patchWorkItem).toHaveBeenLastCalledWith( + item.id, + field === 'labels' ? { labels: ['upstream-original-label'] } : { state: 'open' }, + item.repoId, + { + sourceContext: undefined, + ownerRepo: { owner: 'upstream', repo: 'widgets', host: 'github.com' } + } + ) + } + ) + + it.each([ + { button: 'Toggle label', field: 'labels', expected: 'upstream-original-label' }, + { button: 'Close issue', field: 'state', expected: 'open' } + ])( + 'a same-item failed $field edit still restores its prior parent value', + async ({ button, field, expected }) => { + mocks.update.mockRejectedValue(new Error('same issue edit failed')) + await renderParent({ ...item, labels: ['upstream-original-label'] }) + await click(button) + expect(container.querySelector(`[data-field="${field}"]`)?.textContent).toBe(expected) + } + ) +}) diff --git a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx index f92c99b2a09..a50f41fc960 100644 --- a/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx +++ b/src/renderer/src/components/github-item-dialog/edit-item-fields/gh-edit-section.tsx @@ -3,11 +3,8 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' -import { - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../../../shared/task-source-context' -import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import { getTaskSourceRuntimeSettings } from '../../../../../shared/task-source-context' +import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getTaskPageGitHubDuplicateCandidates, @@ -17,7 +14,8 @@ import { } from '@/components/task-page-github-status-actions' import { parseOwnerRepoFromItemUrl } from '@/components/github/github-work-item-identity' import { translate } from '@/i18n/i18n' -import type { GitHubItemDialogProjectOrigin } from '../load-item-details/github-item-dialog-types' +import type { GitHubItemDialogEditSectionProps } from '../load-item-details/github-item-dialog-types' +import { useMountedRef } from '@/hooks/useMountedRef' import { getGitHubRepositoryLabelsUrl } from './repository-labels-url' import { closeGHEditAsDuplicate, @@ -29,7 +27,14 @@ import { GHEditSectionTopColumns } from './gh-edit-section-top-columns' import { GHEditSectionHorizontal } from './gh-edit-section-horizontal' import { useGitHubDuplicateIssueCandidates } from '@/components/github/github-duplicate-issue-candidates' -export function GHEditSection({ +export function GHEditSection(props: GitHubItemDialogEditSectionProps): React.JSX.Element | null { + const { item, projectOrigin } = props + const repository = projectOrigin ?? parseOwnerRepoFromItemUrl(item.url) + const itemKey = `${item.repoId}\0${repository ? githubRepoIdentityKey(repository) : item.url}\0${item.type}\0${item.id}` + return +} + +function GHEditSectionItem({ item, repoPath, repoId, @@ -45,25 +50,7 @@ export function GHEditSection({ onOpenOrUse, attachedWorkspaceLabel, layout = 'horizontal' -}: { - item: GitHubWorkItem - repoPath: string | null - repoId: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: GitHubItemDialogProjectOrigin | undefined - localState: GitHubWorkItem['state'] - localLabels: string[] - onStateChange: (state: GitHubWorkItem['state']) => void - onLabelsChange: (labels: string[]) => void - /** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */ - onMutated: () => void - assignees: string[] - onUse: (item: GitHubWorkItem) => void - onOpenOrUse?: (item: GitHubWorkItem) => void - attachedWorkspaceLabel?: string | null - /** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */ - layout?: 'horizontal' | 'top-columns' -}): React.JSX.Element | null { +}: GitHubItemDialogEditSectionProps): React.JSX.Element | null { const [labelPopoverOpen, setLabelPopoverOpen] = useState(false) const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) const [statusPopoverOpen, setStatusPopoverOpen] = useState(false) @@ -72,6 +59,7 @@ export function GHEditSection({ const [duplicateError, setDuplicateError] = useState(null) const [localAssignees, setLocalAssignees] = useState(assignees) const editedAssigneesItemKeyRef = useRef(null) + const mountedRef = useMountedRef() const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) @@ -101,13 +89,18 @@ export function GHEditSection({ [projectOrigin, patchProjectRowContent] ) - // Why: with projectOrigin set, read labels/assignees from the row's repo, not the workspace path, or popovers list a different repo than writes target. + const issueRepo = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const metadataOptions = useMemo( + () => ({ ...sourceSettings, ownerRepo: issueRepo }), + [sourceSettings, issueRepo] + ) + // Project metadata comes from the row repository. const slugOwner = projectOrigin?.owner ?? null const slugRepo = projectOrigin?.repo ?? null const repoLabelsByPath = useRepoLabels( projectOrigin ? null : repoPath, projectOrigin ? null : repoId, - sourceSettings + metadataOptions ) const repoLabelsBySlug = useRepoLabelsBySlug( slugOwner, @@ -120,7 +113,7 @@ export function GHEditSection({ const repoAssigneesByPath = useRepoAssignees( projectOrigin ? null : repoPath, projectOrigin ? null : repoId, - sourceSettings + metadataOptions ) const repoAssigneesBySlug = useRepoAssigneesBySlug( slugOwner, @@ -187,8 +180,13 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, run, - onStateChange, + onStateChange: (state) => { + if (mountedRef.current) { + onStateChange(state) + } + }, patchWorkItem, patchProjectRowIfNeeded, onMutated @@ -202,9 +200,11 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, patchWorkItem, patchProjectRowIfNeeded, run, + mountedRef, onStateChange, onMutated ] @@ -253,8 +253,13 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, run, - onLabelsChange, + onLabelsChange: (labels) => { + if (mountedRef.current) { + onLabelsChange(labels) + } + }, patchWorkItem, patchProjectRowIfNeeded, onMutated @@ -268,9 +273,11 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, patchWorkItem, patchProjectRowIfNeeded, run, + mountedRef, onLabelsChange, onMutated ] @@ -288,6 +295,7 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, run, setLocalAssignees, patchProjectRowIfNeeded, @@ -301,6 +309,7 @@ export function GHEditSection({ repoPath, sourceContext, projectOrigin, + issueRepo, localAssignees, patchProjectRowIfNeeded, run, diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts b/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts index c0a6fc7a3b8..9c8e81953b2 100644 --- a/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts +++ b/src/renderer/src/components/github-item-dialog/load-item-details/github-item-dialog-types.ts @@ -26,3 +26,23 @@ export type GitHubItemDialogProps = { /** Optional Project-origin context; when set, edits route via slug-addressed IPCs against the row's repo (slug routing wins for writes). */ projectOrigin?: GitHubItemDialogProjectOrigin } + +export type GitHubItemDialogEditSectionProps = { + item: GitHubWorkItem + repoPath: string | null + repoId: string | null + sourceContext?: TaskSourceContext | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + localState: GitHubWorkItem['state'] + localLabels: string[] + onStateChange: (state: GitHubWorkItem['state']) => void + onLabelsChange: (labels: string[]) => void + /** Why: lets the parent invalidate its details cache after a mutation, else a reopen within FRESH_MS paints pre-mutation data. */ + onMutated: () => void + assignees: string[] + onUse: (item: GitHubWorkItem) => void + onOpenOrUse?: (item: GitHubWorkItem) => void + attachedWorkspaceLabel?: string | null + /** `horizontal`: compact pill strip for the non-issue drawer/header; `top-columns`: labeled columns above the issue page body. */ + layout?: 'horizontal' | 'top-columns' +} diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts b/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts index 0d685da6020..c9bd69d9bb1 100644 --- a/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts +++ b/src/renderer/src/components/github-item-dialog/load-item-details/use-github-item-dialog-details.ts @@ -4,6 +4,7 @@ import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-sou import { canUseGitHubRepoContext } from '@/lib/github-source-runtime-context' import { normalizeItemDialogTab, + parseOwnerRepoFromItemUrl, type ItemDialogTab } from '@/components/github/github-work-item-identity' import type { PRComment } from '../../../../../shared/github/comment-types' @@ -61,6 +62,13 @@ export function useGitHubItemDialogDetails({ ?.issueSourcePreference }) const canUseDetailsRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) + const issueRepository = useMemo( + () => + workItem?.type === 'issue' + ? (projectOrigin ?? parseOwnerRepoFromItemUrl(workItem.url)) + : null, + [projectOrigin, workItem] + ) const detailsCacheKey = useMemo(() => { if (!workItem || !effectiveRepoId || !canUseDetailsRepoContext) { return null @@ -69,10 +77,12 @@ export function useGitHubItemDialogDetails({ repoPath: repoPath ?? '', repoId: effectiveRepoId, issueSourcePreference, + sourceContext, sourceCacheScope: sourceContext?.provider === 'github' ? getTaskSourceCacheScope(sourceContext) : null, type: workItem.type, - number: workItem.number + number: workItem.number, + ownerRepo: issueRepository }) }, [ canUseDetailsRepoContext, @@ -80,7 +90,8 @@ export function useGitHubItemDialogDetails({ effectiveRepoId, sourceContext, workItem, - issueSourcePreference + issueSourcePreference, + issueRepository ]) // Why: reset during render so an item switch never paints the previous item's tab. @@ -97,7 +108,7 @@ export function useGitHubItemDialogDetails({ } // Why: hold comments added before the detail fetch resolves so they merge into the result instead of being overwritten. - const optimisticCommentsRef = useRef([]) + const optimisticCommentsRef = useRef(new Map()) // Why: distinguish "reopen same item" from "switch item" — reopen must keep optimistic comments since gh's 60s cache omits the just-posted one. const prevItemIdRef = useRef(null) @@ -116,7 +127,7 @@ export function useGitHubItemDialogDetails({ // Why: key off cachedEntry identity (stable), not the optimistic ref array (fresh each render), to avoid needless recompute. const details = useMemo(() => { const cachedDetails = cachedEntry?.details ?? null - const opt = optimisticCommentsRef.current + const opt = optimisticCommentsRef.current.get(detailsCacheKey ?? '') ?? [] if (!cachedDetails) { // Why: on cold open, details may still be loading — surface optimistic comments via a minimal shell so a pre-fetch comment isn't invisible. if (opt.length > 0 && workItem) { @@ -138,7 +149,7 @@ export function useGitHubItemDialogDetails({ } // Why: optimisticTick forces this ref-reading memo to re-run on cold-open writes; lint can't see the dependency. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cachedEntry, workItem, optimisticTick]) + }, [cachedEntry, workItem, detailsCacheKey, optimisticTick]) const loading = !!cachedEntry?.pending && !cachedEntry?.details const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null @@ -158,7 +169,7 @@ export function useGitHubItemDialogDetails({ } // Why: clear optimistic comments only on item switch — on reopen, gh's 60s cache omits the just-posted comment, so keep the ref to re-merge. if (workItem.id !== prevItemIdRef.current) { - optimisticCommentsRef.current = [] + optimisticCommentsRef.current.clear() } prevItemIdRef.current = workItem.id @@ -178,7 +189,8 @@ export function useGitHubItemDialogDetails({ repoId: effectiveRepoId, sourceContext, number: workItem.number, - type: workItem.type + type: workItem.type, + ownerRepo: issueRepository }) // Why: snapshot the invalidation generation; if it advances before resolve, a mid-flight mutation invalidated the entry — don't write back. @@ -201,6 +213,7 @@ export function useGitHubItemDialogDetails({ sourceContext, workItem, detailsCacheKey, + issueRepository, refetchTick ]) @@ -231,7 +244,9 @@ export function useGitHubItemDialogDetails({ (comment: PRComment) => { useAppStore.getState().recordFeatureInteraction('github-tasks') // Why: skip refreshDetails() — gh's 60s cache would overwrite the optimistic comment; next open picks up the server version. - optimisticCommentsRef.current.push(comment) + const optimisticKey = detailsCacheKey ?? '' + const optimisticComments = optimisticCommentsRef.current.get(optimisticKey) ?? [] + optimisticCommentsRef.current.set(optimisticKey, [...optimisticComments, comment]) // Why: write through the module cache so concurrent drawers re-render; mark fetchedAt stale (0) so next open refetches server fields. if (detailsCacheKey) { const prev = workItemDetailsCache.get(detailsCacheKey) diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts index 36087ef00b2..38811153a28 100644 --- a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts +++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-cache.ts @@ -1,9 +1,13 @@ import type { PRCheckDetail } from '../../../../../shared/github/check-types' import type { GitHubAssignableUser, + GitHubOwnerRepo, GitHubPRFileViewedState } from '../../../../../shared/github/pull-request-types' +import { githubRepoIdentityKey } from '../../../../../shared/github/repository-identity-key' import type { GitHubWorkItemDetails } from '../../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { getGitHubSourceRuntimeHost } from '@/lib/github-source-runtime-context' import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' // Why: SWR cache for work-item details so reopening paints instantly instead of paying IPC + `gh` startup; keyed to avoid source/type collisions, LRU-bounded, FRESH_MS refetch on open. See docs/gh-work-item-drawer-cache.md. @@ -37,20 +41,20 @@ export function getWorkItemDetailsCacheKey(args: { repoId: string issueSourcePreference: string | undefined sourceCacheScope?: string | null + sourceContext?: TaskSourceContext | null type: 'issue' | 'pr' number: number + ownerRepo?: GitHubOwnerRepo | null }): string { // Why: key on every axis that changes which (repo, item) the IPC resolves to; `\0` separator avoids ambiguity with fields containing `:` or `/`. // Why: repoPath is the second part so match-based invalidation can find entries from a cross-window event that carries only the path. + const sourceKey = + args.ownerRepo && !getGitHubSourceRuntimeHost(args.sourceContext) + ? githubRepoIdentityKey(args.ownerRepo) + : (args.issueSourcePreference ?? 'auto') const keyParts = args.sourceCacheScope - ? [ - args.repoId, - args.repoPath, - args.sourceCacheScope, - args.issueSourcePreference ?? 'auto', - args.type - ] - : [args.repoId, args.repoPath, args.issueSourcePreference ?? 'auto', args.type] + ? [args.repoId, args.repoPath, args.sourceCacheScope, sourceKey, args.type] + : [args.repoId, args.repoPath, sourceKey, args.type] return [...keyParts, args.number].join('\0') } diff --git a/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts new file mode 100644 index 00000000000..a5c7ab456c8 --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/load-item-details/work-item-details-issue-target.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { + getWorkItemDetailsCacheKey, + invalidateWorkItemDetailsCacheByMatch, + touchWorkItemDetailsCache, + workItemDetailsCache +} from './work-item-details-cache' + +vi.mock('@/lib/github-work-item-details-cache-events', () => ({ + onGitHubWorkItemDetailsCacheMutation: vi.fn() +})) + +const keyArgs = { + repoId: 'repo-1', + repoPath: '/home/fixture/widgets', + type: 'issue' as const, + number: 12, + issueSourcePreference: 'origin' +} +const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } +const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' } +const LOCAL_SOURCE: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1' +} + +describe('issue detail cache repository identity', () => { + it('separates equal issue numbers across origin and upstream', () => { + expect(getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: ORIGIN })).not.toBe( + getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: UPSTREAM }) + ) + }) + + it.each([undefined, LOCAL_SOURCE])( + 'keeps a local opened issue key stable when another window changes the selector: %j', + (sourceContext) => { + expect(getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: ORIGIN, sourceContext })).toBe( + getWorkItemDetailsCacheKey({ + ...keyArgs, + issueSourcePreference: 'upstream', + ownerRepo: ORIGIN, + sourceContext + }) + ) + } + ) + + it('keeps RPC detail caches scoped to the preference used by their existing lookup', () => { + const sourceContext: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'runtime:env-1', + repoId: 'runtime-repo' + } + const args = { ...keyArgs, ownerRepo: ORIGIN, sourceContext } + + expect(getWorkItemDetailsCacheKey(args)).not.toBe( + getWorkItemDetailsCacheKey({ ...args, issueSourcePreference: 'upstream' }) + ) + expect(getWorkItemDetailsCacheKey(args)).toBe( + getWorkItemDetailsCacheKey({ ...args, ownerRepo: null }) + ) + }) + + it('invalidates both repository variants after a mutation', () => { + const originKey = getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: ORIGIN }) + const upstreamKey = getWorkItemDetailsCacheKey({ ...keyArgs, ownerRepo: UPSTREAM }) + touchWorkItemDetailsCache(originKey, { details: null, fetchedAt: 0 }) + touchWorkItemDetailsCache(upstreamKey, { details: null, fetchedAt: 0 }) + + invalidateWorkItemDetailsCacheByMatch(keyArgs) + + expect(workItemDetailsCache.has(originKey)).toBe(false) + expect(workItemDetailsCache.has(upstreamKey)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/github/PRAssigneesPanel.tsx b/src/renderer/src/components/github/PRAssigneesPanel.tsx index d759d93880a..916c9f5d413 100644 --- a/src/renderer/src/components/github/PRAssigneesPanel.tsx +++ b/src/renderer/src/components/github/PRAssigneesPanel.tsx @@ -11,6 +11,7 @@ import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { parseOwnerRepoFromItemUrl, + resolvePullRequestRepo, type GitHubWorkItemProjectOrigin } from '@/components/github/github-work-item-identity' import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations' @@ -82,6 +83,7 @@ export function PRAssigneesPanel({ ) const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees]) const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const prRepo = useMemo(() => resolvePullRequestRepo(item, projectOrigin), [item, projectOrigin]) const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null const repoAssigneesBySlug = useRepoAssigneesBySlug( @@ -118,17 +120,24 @@ export function PRAssigneesPanel({ repoPath, sourceContext, projectOrigin, + issueRepo: prRepo, number: item.number, updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] } }), onOptimistic: () => { setLocalAssignees(nextAssignees) - patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) + patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { + sourceContext, + ownerRepo: prRepo + }) patchProjectRowIfNeeded(nextLogins) }, onRevert: () => { setLocalAssignees(prevAssignees) - patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { sourceContext }) + patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { + sourceContext, + ownerRepo: prRepo + }) patchProjectRowIfNeeded(prevLogins) }, onSuccess: () => { @@ -147,6 +156,7 @@ export function PRAssigneesPanel({ onMutated, patchProjectRowIfNeeded, patchWorkItem, + prRepo, projectOrigin, repoPath, run, diff --git a/src/renderer/src/components/github/github-work-item-comment-mutations.ts b/src/renderer/src/components/github/github-work-item-comment-mutations.ts index f0f6fda3b82..6c7fc7e99ac 100644 --- a/src/renderer/src/components/github/github-work-item-comment-mutations.ts +++ b/src/renderer/src/components/github/github-work-item-comment-mutations.ts @@ -25,6 +25,7 @@ export function addIssueCommentForRepo(args: { repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), number: args.number, body: args.body, + ...(args.type ? { type: args.type } : {}), prRepo: args.prRepo ?? null }, { timeoutMs: 30_000 } diff --git a/src/renderer/src/components/github/github-work-item-edit-mutations.ts b/src/renderer/src/components/github/github-work-item-edit-mutations.ts index 5c40aeb2328..543fb2395e8 100644 --- a/src/renderer/src/components/github/github-work-item-edit-mutations.ts +++ b/src/renderer/src/components/github/github-work-item-edit-mutations.ts @@ -29,19 +29,21 @@ export async function runIssueUpdate(args: { repoId?: string | null sourceContext?: TaskSourceContext | null projectOrigin: GitHubWorkItemProjectOrigin | undefined + issueRepo?: GitHubOwnerRepo | null number: number updates: Parameters[0]['updates'] }): Promise { - if (args.projectOrigin) { + const issueRepo = args.projectOrigin + if (issueRepo) { const targetSettings = args.sourceContext?.provider === 'github' ? getTaskSourceRuntimeSettings(args.sourceContext) : getGitHubMutationSettings(args.repoId) const target = getActiveRuntimeTarget(targetSettings) const updateArgs = { - owner: args.projectOrigin.owner, - repo: args.projectOrigin.repo, - host: githubProjectHost(args.projectOrigin.host), + owner: issueRepo.owner, + repo: issueRepo.repo, + host: githubProjectHost(issueRepo.host), number: args.number, updates: args.updates } @@ -59,18 +61,16 @@ export async function runIssueUpdate(args: { if (!res.ok) { throw new Error(res.error.message) } - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - type: 'issue', - number: args.number - }, - { local: false } - ) - } + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, + type: 'issue', + number: args.number + }, + { local: target.kind !== 'environment' } + ) return } const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) @@ -93,7 +93,8 @@ export async function runIssueUpdate(args: { repoId: args.repoId ?? undefined, sourceContext: args.sourceContext, number: args.number, - updates: args.updates + updates: args.updates, + ...(args.issueRepo ? { ownerRepo: args.issueRepo } : {}) }) if (!res.ok) { throw new Error(res.error) @@ -177,6 +178,7 @@ export async function runWorkItemBodyUpdate(args: { repoId: args.item.repoId, sourceContext: args.sourceContext, projectOrigin: args.projectOrigin, + issueRepo: args.parsedSlug, number: args.item.number, updates: { body: args.body } }) diff --git a/src/renderer/src/components/github/github-work-item-issue-target.test.ts b/src/renderer/src/components/github/github-work-item-issue-target.test.ts new file mode 100644 index 00000000000..88056a25cbb --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-issue-target.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runIssueUpdate, runWorkItemBodyUpdate } from './github-work-item-edit-mutations' +import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import { runGHEditLabelToggle } from '../github-item-dialog/edit-item-fields/gh-edit-section-mutations' + +vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } })) +vi.mock('@/components/github/github-work-item-comment-mutations', () => ({ + notifyWorkItemDetailsMutation: vi.fn() +})) + +const ORIGIN = { owner: 'fork-owner', repo: 'widgets', host: 'github.com' } +const UPSTREAM = { owner: 'upstream-owner', repo: 'widgets', host: 'github.com' } +const projectOrigin = { + ...UPSTREAM, + number: 12, + type: 'issue' as const, + projectId: 'project-1', + projectItemId: 'project-item-12', + cacheKey: 'project-cache' +} +const localSource: TaskSourceContext = { + kind: 'task-source', + provider: 'github', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1' +} +const item: GitHubWorkItem = { + id: 'issue:12', + type: 'issue', + number: 12, + title: 'Origin issue', + state: 'open', + url: 'https://github.com/fork-owner/widgets/issues/12', + labels: [], + updatedAt: '', + author: null, + repoId: 'repo-1' +} + +describe('issue edits retain the displayed repository', () => { + beforeEach(() => { + vi.stubGlobal('window', { + api: { + gh: { updateIssue: vi.fn().mockResolvedValue({ ok: true }), updateIssueBySlug: vi.fn() } + } + }) + }) + + it('keeps body edits on the registered repo path and passes the opened issue target', async () => { + await runWorkItemBodyUpdate({ + item, + repoPath: '/home/fixture/widgets', + projectOrigin: undefined, + body: 'Origin edit', + parsedSlug: ORIGIN + }) + + expect(window.api.gh.updateIssue).toHaveBeenCalledWith({ + repoId: 'repo-1', + repoPath: '/home/fixture/widgets', + sourceContext: undefined, + number: 12, + updates: { body: 'Origin edit' }, + ownerRepo: ORIGIN + }) + expect(window.api.gh.updateIssueBySlug).not.toHaveBeenCalled() + }) + + it('saves a Project row body in its upstream repository instead of the fork workspace', async () => { + vi.mocked(window.api.gh.updateIssueBySlug).mockResolvedValue({ ok: true }) + + await runWorkItemBodyUpdate({ + item: { ...item, url: 'https://github.com/upstream-owner/widgets/issues/12' }, + repoPath: '/home/fixture/fork-widgets', + sourceContext: localSource, + projectOrigin, + body: 'Project edit', + parsedSlug: UPSTREAM + }) + + expect(window.api.gh.updateIssueBySlug).toHaveBeenCalledWith({ + ...UPSTREAM, + number: 12, + updates: { body: 'Project edit' } + }) + expect(window.api.gh.updateIssue).not.toHaveBeenCalled() + }) + + it.each([ + { state: 'closed' as const }, + { addLabels: ['bug'] }, + { addAssignees: ['upstream-assignee'] } + ])('keeps Project field edits in the row repository: %j', async (updates) => { + vi.mocked(window.api.gh.updateIssueBySlug).mockResolvedValue({ ok: true }) + + await runIssueUpdate({ + repoPath: '/home/fixture/fork-widgets', + repoId: item.repoId, + sourceContext: localSource, + projectOrigin, + issueRepo: UPSTREAM, + number: 12, + updates + }) + + expect(window.api.gh.updateIssueBySlug).toHaveBeenCalledWith({ + ...UPSTREAM, + number: 12, + updates + }) + expect(window.api.gh.updateIssue).not.toHaveBeenCalled() + }) + + it.each([ + { state: 'closed' as const }, + { addLabels: ['bug'] }, + { addAssignees: ['fork-assignee'] } + ])('pins field edits to the opened issue repository: %j', async (updates) => { + await runIssueUpdate({ + repoPath: 'C:\\workspace\\widgets', + repoId: 'repo-1', + projectOrigin: undefined, + number: 12, + issueRepo: ORIGIN, + updates + }) + + expect(window.api.gh.updateIssue).toHaveBeenCalledWith( + expect.objectContaining({ + repoPath: 'C:\\workspace\\widgets', + ownerRepo: ORIGIN, + updates + }) + ) + expect(window.api.gh.updateIssueBySlug).not.toHaveBeenCalled() + }) + + it.each([ + { localLabels: [], updates: { addLabels: ['bug'] } }, + { localLabels: ['bug'], updates: { removeLabels: ['bug'] } } + ])( + 'retains the target for both label toggle directions: %j', + async ({ localLabels, updates }) => { + let mutation: Promise = Promise.resolve() + runGHEditLabelToggle({ + itemId: item.id, + itemNumber: item.number, + itemRepoId: item.repoId, + repoPath: 'C:\\workspace\\widgets', + projectOrigin: undefined, + issueRepo: ORIGIN, + label: 'bug', + localLabels, + run: async (_key, options) => { + mutation = options.mutate() + await mutation + }, + onLabelsChange: vi.fn(), + patchWorkItem: vi.fn(), + patchProjectRowIfNeeded: vi.fn(), + onMutated: vi.fn() + }) + await mutation + + expect(window.api.gh.updateIssue).toHaveBeenCalledWith( + expect.objectContaining({ ownerRepo: ORIGIN, updates }) + ) + } + ) +}) diff --git a/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts b/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts index 1a6ab8672a0..a911d58ad62 100644 --- a/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts +++ b/src/renderer/src/components/maintenance/update-card/update-card-error-model.ts @@ -47,6 +47,18 @@ export function buildUpdateCardErrorModel({ } : null } + if (status.retryAction === 'install' && status.retryable !== false) { + return { + title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'), + summary: status.message, + detail: status.message, + releaseUrl: getReleaseNotesUrlForVersion(cachedVersion), + primaryAction: { + label: translate('auto.components.UpdateCard.2c2d3e03ca', 'Try Again'), + onClick: onInstallRetry + } + } + } if (isLocalBuild) { return { title: cachedVersion diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 460df23ef8b..c5553f00edb 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -185,9 +185,20 @@ vi.mock('./NativeChatQuestionCard', () => ({ import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage' +function useProbeClock(): void { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) +} + +async function advanceProbeClock(milliseconds: number): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds) + }) +} + describe('NativeChatStructuredSession delivery', () => { afterEach(() => { cleanup() + vi.useRealTimers() mocks.call.mockReset() mocks.mode = 'static' mocks.messageListProps = null @@ -461,6 +472,7 @@ describe('NativeChatStructuredSession delivery', () => { }) it('resends a transport-unconfirmed head so later messages are not wedged', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ @@ -482,17 +494,25 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() + expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() - expect(send?.('second', [])).toBe(true) + await act(async () => { + expect(send?.('second', [])).toBe(true) + }) + await advanceProbeClock(999) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) // The head is probed automatically, clears, and the queue drains. - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3), { timeout: 10000 }) - await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) + expect(mocks.call).toHaveBeenCalledTimes(3) + expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() }, 20000) it('probes the same operation without marking an explicit user retry', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ @@ -514,8 +534,13 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 10000 }) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + await advanceProbeClock(999) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) + expect(mocks.call).toHaveBeenCalledTimes(2) const first = mocks.call.mock.calls[0]?.[2] as Record const probe = mocks.call.mock.calls[1]?.[2] as Record @@ -527,6 +552,7 @@ describe('NativeChatStructuredSession delivery', () => { }, 20000) it('parks a host-confirmed unknown instead of probing it', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ ok: true, @@ -547,8 +573,10 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } } // The host now reports an unresolved unknown: another replay is the user's call. @@ -569,13 +597,12 @@ describe('NativeChatStructuredSession delivery', () => { await act(async () => { send?.('second', []) }) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) + await advanceProbeClock(3000) expect(mocks.call).toHaveBeenCalledOnce() }, 20000) it('still probes while streaming batches rebuild the submissions array', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ @@ -598,8 +625,10 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() // Each batch mints a fresh submissions array for an unrelated message. An // array-identity dependency restarts the backoff on every one of these, so a @@ -619,7 +648,7 @@ describe('NativeChatStructuredSession delivery', () => { ] await act(async () => { rerender(makeView()) - await new Promise((resolve) => setTimeout(resolve, 250)) + await vi.advanceTimersByTimeAsync(250) }) } @@ -629,6 +658,7 @@ describe('NativeChatStructuredSession delivery', () => { }, 20000) it('restarts probe delay when the runtime target changes', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ ok: true, @@ -651,22 +681,24 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 300)) - }) - rerender(makeView({ kind: 'environment', environmentId: 'env-1' })) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 600)) + expect(send?.('first', [])).toBe(true) }) expect(mocks.call).toHaveBeenCalledOnce() - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 1500 }) + expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() + + await advanceProbeClock(300) + rerender(makeView({ kind: 'environment', environmentId: 'env-1' })) + await advanceProbeClock(600) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(399) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) + expect(mocks.call).toHaveBeenCalledTimes(2) }, 10000) it('never auto-probes an entry the user already force-retried', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.submissions = [] // Both the original send and the user's explicit Retry fail at the transport. @@ -686,25 +718,28 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) + await act(async () => { + expect(send?.('first', [])).toBe(true) + }) + expect(mocks.call).toHaveBeenCalledOnce() + expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() // User retries with the same envelope and no legacy redelivery signal. - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Retry/ })) + }) + expect(mocks.call).toHaveBeenCalledTimes(2) const forcedRequest = mocks.call.mock.calls[1]?.[2] as Record | undefined expect(forcedRequest?.retryUnknown).toBeUndefined() // That retry also failed at the transport. The probe must not repeat an // explicit retry automatically. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) + await advanceProbeClock(3000) expect(mocks.call).toHaveBeenCalledTimes(2) }, 20000) it('does not hot-loop when the host answers pending', async () => { + useProbeClock() mocks.mode = 'outbox' mocks.call.mockResolvedValue({ ok: true, @@ -725,15 +760,18 @@ describe('NativeChatStructuredSession delivery', () => { const send = mocks.composerProps?.structuredTransport?.send as | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) | undefined - expect(send?.('first', [])).toBe(true) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - - // A pending row parks the entry under the backoff instead of re-dispatching - // immediately. Without that, this window is an unbounded back-to-back RPC flood. await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 2500)) + expect(send?.('first', [])).toBe(true) }) - expect(mocks.call.mock.calls.length).toBeLessThanOrEqual(3) + expect(mocks.call).toHaveBeenCalledOnce() + + // A host-pending entry stays parked until the journal answers it. + await advanceProbeClock(999) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1) + expect(mocks.call).toHaveBeenCalledOnce() + await advanceProbeClock(1500) + expect(mocks.call).toHaveBeenCalledOnce() }, 20000) it('keeps probing past the old five-attempt budget', async () => { diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx index d83cf01b19b..e5bfb32be4f 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx @@ -391,7 +391,7 @@ describe('StructuredAgentSessionAttentionBridge', () => { }) }) - it('keeps a published paired chat subscribed and delivers after a workspace-id collision', async () => { + it('keeps a legacy published paired chat subscribed and delivers after a workspace-id collision', async () => { const store = mocks.store if (!store) { throw new Error('test store was not initialized') @@ -445,10 +445,15 @@ describe('StructuredAgentSessionAttentionBridge', () => { 100 ) ) - const tab = store.getState().unifiedTabsByWorktree[WORKSPACE][0] - if (!tab) { + const publishedTab = store.getState().unifiedTabsByWorktree[WORKSPACE][0] + if (!publishedTab) { throw new Error('snapshot did not publish a chat tab') } + // Why: restored tabs from before host stamping can still have ambiguous catalog ownership. + const { executionHostId, ...legacyTab } = publishedTab + expect(executionHostId).toBe('runtime:env-1') + const tab = makeUnifiedTab(legacyTab) + store.setState({ unifiedTabsByWorktree: { [WORKSPACE]: [tab] } }) expect(tab.contentType).toBe('agent-session') expect(tab.executionHostId).toBeUndefined() render() diff --git a/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx b/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx index b1fc60b7d5e..f0b5b27d486 100644 --- a/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx +++ b/src/renderer/src/components/right-sidebar/SearchResultsPane.tsx @@ -13,6 +13,7 @@ const SEARCH_VIRTUAL_OVERSCAN = 12 type SearchResultsPaneProps = { results: SearchResult | null + error?: string | null hasCommittedResults: boolean query: string loading: boolean @@ -25,6 +26,7 @@ type SearchResultsPaneProps = { export function SearchResultsPane({ results, hasCommittedResults, + error, query, loading, rows, @@ -68,7 +70,7 @@ export function SearchResultsPane({ <> {/* Why: the summary is rendered outside the virtualizer so it stays pinned at the top while the user scrolls through results. */} - {results && rows.length > 0 && ( + {!error && results && (rows.length > 0 || results.truncated) && (
{results.totalMatches}{' '} {translate('auto.components.right.sidebar.Search.6aeda362ed', 'result')} @@ -83,7 +85,15 @@ export function SearchResultsPane({ )}
- {rows.length > 0 && ( + {error && ( +
+ {error} +
+ )} + {!error && rows.length > 0 && (
{virtualizer.getVirtualItems().map((virtualRow) => { const row = rows[virtualRow.index] @@ -117,7 +127,7 @@ export function SearchResultsPane({
)} - {!hasCommittedResults && query && !loading && ( + {!error && !hasCommittedResults && query && !loading && (
{translate('auto.components.right.sidebar.Search.d56d140747', 'Press Enter to search')}
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts new file mode 100644 index 00000000000..3dbc25998d2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-filename-identity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { fileExplorerEntriesToTreeNodes } from './file-explorer-directory-listing' + +describe('Explorer directory filename identity', () => { + it.each(['/', '/repo/', 'C:\\', 'C:\\repo\\', '\\\\server\\share\\'])( + 'preserves the first filename character when the root ends in a separator: %s', + (root) => { + const [node] = fileExplorerEntriesToTreeNodes( + [{ name: 'abc.txt', isDirectory: false, isSymlink: false }], + root, + -1, + root, + { kind: 'local' } + ) + expect(node.relativePath).toBe('abc.txt') + } + ) + + it.each(['/repo', '/ssh/repo\\root'])( + 'keeps literal and nested POSIX paths distinct under %s', + (root) => { + const literal = fileExplorerEntriesToTreeNodes( + [{ name: 'a\\b.txt', isDirectory: false, isSymlink: false }], + root, + -1, + root, + { kind: 'local' } + )[0] + const nested = fileExplorerEntriesToTreeNodes( + [{ name: 'b.txt', isDirectory: false, isSymlink: false }], + `${root}/a`, + 0, + root, + { kind: 'local' } + )[0] + expect(literal).toMatchObject({ path: `${root}/a\\b.txt`, relativePath: 'a\\b.txt' }) + expect(nested).toMatchObject({ path: `${root}/a/b.txt`, relativePath: 'a/b.txt' }) + } + ) + + it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'keeps Windows relative paths canonical under %s', + (root) => { + const [node] = fileExplorerEntriesToTreeNodes( + [{ name: 'b.txt', isDirectory: false, isSymlink: false }], + `${root}\\a`, + 0, + root, + { kind: 'local' } + ) + expect(node).toMatchObject({ path: `${root}\\a\\b.txt`, relativePath: 'a/b.txt' }) + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts index d904c910e0b..e94a32e3fa5 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts @@ -1,4 +1,4 @@ -import { joinPath, normalizeRelativePath } from '@/lib/path' +import { getRelativePathInsideRoot, joinPath } from '@/lib/path' import type { DirEntry } from '../../../../shared/filesystem-entry-types' import { sortDirEntries } from '../../../../shared/file-name-sort' import { readRuntimeDirectory } from '@/runtime/runtime-file-client' @@ -27,9 +27,7 @@ export function fileExplorerEntriesToTreeNodes( return { name: entry.name, path, - relativePath: worktreePath - ? normalizeRelativePath(path.slice(worktreePath.length + 1)) - : entry.name, + relativePath: getRelativePathInsideRoot(path, worktreePath) ?? entry.name, isDirectory: entry.isDirectory, isSymlink: entry.isSymlink, depth: depth + 1, diff --git a/src/renderer/src/components/right-sidebar/file-explorer-entries.ts b/src/renderer/src/components/right-sidebar/file-explorer-entries.ts index f21116c7822..d909224675b 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-entries.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-entries.ts @@ -1,4 +1,5 @@ import type { DirEntry } from '../../../../shared/filesystem-entry-types' +import { splitPathSegments } from './path-tree' export function shouldIncludeFileExplorerEntry(entry: DirEntry): boolean { return entry.name !== '.git' && entry.name !== 'node_modules' @@ -8,6 +9,6 @@ function isDotfileSegment(segment: string): boolean { return segment.length > 1 && segment !== '..' && segment.startsWith('.') } -export function isDotfileRelativePath(relativePath: string): boolean { - return relativePath.split(/[\\/]+/).some(isDotfileSegment) +export function isDotfileRelativePath(relativePath: string, rootPath?: string | null): boolean { + return splitPathSegments(relativePath, rootPath).some(isDotfileSegment) } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts index 6075b8c36a5..f883ec8544a 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest' import { + createNameFilteredFileExplorerProjection, + getFileExplorerNameFilterIgnoredQueryRelativePaths, getNameFilterCollapsedPathsAfterExpand, getNextNameFilterCollapsedPaths } from './file-explorer-name-filter-projection' +import { buildIgnoredSet } from './status-display' describe('getNextNameFilterCollapsedPaths', () => { it('collapses expanded filtered folders and expands collapsed filtered folders', () => { @@ -22,3 +25,73 @@ describe('getNextNameFilterCollapsedPaths', () => { expect([...expanded]).toEqual(['/repo/docs']) }) }) + +describe('name-filter filename identity', () => { + function project( + worktreePath: string, + relativePaths: string[], + options: { displayRootPath?: string; ignoredPaths?: string[]; showDotfiles?: boolean } = {} + ) { + return createNameFilteredFileExplorerProjection({ + worktreePath, + displayRootPath: options.displayRootPath, + nameFilter: { query: 'txt', relativePaths }, + ignoredSet: buildIgnoredSet(options.ignoredPaths, worktreePath), + showDotfiles: options.showDotfiles ?? true, + showGitIgnoredFiles: false + }).getVisibleSlice(0, 100) + } + + it.each(['/native/repo', '/ssh/repo\\root'])( + 'keeps literal backslashes separate from directories under %s', + (root) => { + const paths = ['a\\b.txt', 'a/b.txt', 'C:\\foo/a\\b.txt', '\\\\server/a\\b.txt'] + const files = project(root, paths).filter((row) => !row.isDirectory) + expect(files.map((row) => row.relativePath).sort()).toEqual([...paths].sort()) + expect(files.map((row) => row.path).sort()).toEqual( + paths.map((path) => `${root}/${path}`).sort() + ) + } + ) + + it('scopes a literal-backslash directory without selecting its nested counterpart', () => { + const rows = project('/repo', ['a\\b/file.txt', 'a/b/other.txt'], { + displayRootPath: '/repo/a\\b' + }) + expect(rows.map((row) => row.relativePath)).toEqual(['a\\b/file.txt']) + }) + + it('keeps ignored and dotfile identities distinct on POSIX', () => { + const paths = ['a\\b.txt', 'a/b.txt', 'a\\.hidden.txt', 'a/.hidden.txt'] + const rows = project('/repo', paths, { ignoredPaths: ['a/b.txt'], showDotfiles: false }) + expect(rows.map((row) => row.relativePath).sort()).toEqual(['a\\.hidden.txt', 'a\\b.txt']) + expect( + getFileExplorerNameFilterIgnoredQueryRelativePaths( + { query: 'txt', relativePaths: paths }, + false, + '/repo' + ) + ).toEqual(['a\\b.txt', 'a/b.txt', 'a\\.hidden.txt']) + expect( + project('/repo', paths, { ignoredPaths: ['a\\b.txt'] }).map((row) => row.relativePath) + ).not.toContain('a\\b.txt') + }) + + it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'keeps Windows separator semantics under %s', + (root) => { + const paths = ['a\\b.txt', 'a/b.txt', 'a\\.hidden.txt'] + const files = project(root, paths, { showDotfiles: false }).filter((row) => !row.isDirectory) + expect(files.map((row) => row.relativePath)).toEqual(['a/b.txt']) + expect(files.map((row) => row.path)).toEqual([`${root}\\a\\b.txt`]) + expect(project(root, paths, { ignoredPaths: ['a\\'], showDotfiles: false })).toEqual([]) + expect( + getFileExplorerNameFilterIgnoredQueryRelativePaths( + { query: 'txt', relativePaths: paths }, + false, + root + ) + ).toEqual(['a/b.txt', 'a/b.txt']) + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts index b8d9256219e..5aa8b3c73e9 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts @@ -65,7 +65,8 @@ export function getFileExplorerNameFilterTokens(query: string | undefined): stri export function getFileExplorerNameFilterIgnoredQueryRelativePaths( source: FileExplorerNameFilterProjectionSource, - showDotfiles: boolean + showDotfiles: boolean, + worktreePath?: string | null ): string[] { if (isFileExplorerNameFilterQueryTooLarge(source.query)) { return [] @@ -75,11 +76,11 @@ export function getFileExplorerNameFilterIgnoredQueryRelativePaths( } const tokens = getFileExplorerNameFilterTokens(source.query) return source.relativePaths - .map((relativePath) => normalizeRelativePath(relativePath)) + .map((relativePath) => normalizeRelativePath(relativePath, worktreePath)) .filter( (relativePath) => Boolean(relativePath) && - (showDotfiles || !isDotfileRelativePath(relativePath)) && + (showDotfiles || !isDotfileRelativePath(relativePath, worktreePath)) && pathMatchesFileNameFilterTokens(relativePath, tokens) ) } @@ -139,14 +140,14 @@ export function createNameFilteredFileExplorerProjection({ const rootChildren = new Map() for (const rawRelativePath of nameFilter.relativePaths) { - const relativePath = normalizeRelativePath(rawRelativePath) + const relativePath = normalizeRelativePath(rawRelativePath, worktreePath) if ( !relativePath || getRelativePathInsideRoot(joinPath(worktreePath, relativePath), displayRootPath) === null ) { continue } - if (!showDotfiles && isDotfileRelativePath(relativePath)) { + if (!showDotfiles && isDotfileRelativePath(relativePath, worktreePath)) { continue } if (!showGitIgnoredFiles && isPathIgnored(ignoredSet, relativePath)) { @@ -156,12 +157,12 @@ export function createNameFilteredFileExplorerProjection({ continue } - const segments = splitPathSegments(relativePath) + const segments = splitPathSegments(relativePath, worktreePath) let currentChildren = rootChildren let currentRelativePath = '' for (let index = 0; index < segments.length; index += 1) { const name = segments[index] - currentRelativePath = currentRelativePath ? joinPath(currentRelativePath, name) : name + currentRelativePath = currentRelativePath ? `${currentRelativePath}/${name}` : name const isDirectory = index < segments.length - 1 let entry = currentChildren.get(name) if (!entry) { @@ -186,7 +187,7 @@ export function createNameFilteredFileExplorerProjection({ let displayChildren = rootChildren const scope = getRelativePathInsideRoot(displayRootPath, worktreePath) - for (const segment of scope ? splitPathSegments(scope) : []) { + for (const segment of scope ? splitPathSegments(scope, worktreePath) : []) { const entry = displayChildren.get(segment) if (!entry) { return createFileExplorerRowProjectionFromParts(visibleFlatRows, rowsByPath) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts index bbc373d6ad9..fa96214d0a4 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts @@ -1,15 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - getRevealAncestorDirs, - isPathEqualOrDescendant, - normalizeAbsolutePath -} from './file-explorer-paths' +import { getRevealAncestorDirs, isPathEqualOrDescendant } from './file-explorer-paths' describe('file explorer path helpers', () => { - it('preserves UNC roots while normalizing separators', () => { - expect(normalizeAbsolutePath('\\\\Server\\Share\\Repo\\')).toBe('//Server/Share/Repo') - }) - it('matches Windows drive paths case-insensitively with segment boundaries', () => { expect(isPathEqualOrDescendant('c:\\repo\\src\\a.ts', 'C:\\Repo')).toBe(true) expect(isPathEqualOrDescendant('C:\\Repository\\src\\a.ts', 'C:\\Repo')).toBe(false) @@ -42,4 +34,19 @@ describe('file explorer path helpers', () => { 'C:\\repo\\src' ]) }) + + it.each(['/repo', '/ssh/repo\\root'])( + 'reveals POSIX literal-backslash names without inventing directories under %s', + (root) => { + expect(getRevealAncestorDirs(root, `${root}/a\\b.txt`)).toEqual([]) + expect(getRevealAncestorDirs(root, `${root}/a/b.txt`)).toEqual([`${root}/a`]) + expect(getRevealAncestorDirs(root, `${root}/a\\b/c\\d.txt`)).toEqual([`${root}/a\\b`]) + } + ) + + it('reveals Windows UNC paths with native separators', () => { + expect( + getRevealAncestorDirs('\\\\server\\share\\repo', '\\\\server\\share\\repo\\a\\b.txt') + ).toEqual(['\\\\server\\share\\repo\\a']) + }) }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-paths.ts b/src/renderer/src/components/right-sidebar/file-explorer-paths.ts index 1c2fa044117..ce2f64d8fc3 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-paths.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-paths.ts @@ -1,26 +1,11 @@ -import { joinPath, normalizeRelativePath } from '@/lib/path' +import { joinPath } from '@/lib/path' import { isPathInsideOrEqual, normalizeRuntimePathForComparison, - normalizeRuntimePathSeparators, relativePathInsideRoot } from '../../../../shared/cross-platform-path' import { splitPathSegments } from './path-tree' -export function normalizeAbsolutePath(path: string): string { - const normalizedPath = normalizeRuntimePathSeparators(path) - - if (normalizedPath === '/') { - return normalizedPath - } - - if (/^[A-Za-z]:\/$/.test(normalizedPath)) { - return normalizedPath - } - - return normalizedPath.replace(/\/+$/, '') -} - export function normalizeAbsolutePathForComparison(path: string): string { return normalizeRuntimePathForComparison(path) } @@ -35,7 +20,7 @@ export function getRevealAncestorDirs(worktreePath: string, filePath: string): s return null } - const segments = splitPathSegments(normalizeRelativePath(relativePath)) + const segments = splitPathSegments(relativePath, worktreePath) const ancestorDirs: string[] = [] let currentPath = worktreePath diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts new file mode 100644 index 00000000000..f28474f5913 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-filename-identity.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { clearStalePendingReveal } from './file-explorer-watcher-reconcile' +import { + canonicalizeFileExplorerWatchPath, + getExternalFileChangeRelativePath, + normalizeExplorerAbsolutePath, + parentDirForWatchPath +} from './file-explorer-watch-path' + +describe('Explorer watcher filename identity', () => { + it('does not clear a nested pending reveal when a distinct literal-backslash path is deleted', () => { + const previous = useAppStore.getState().pendingExplorerReveal + const pending = { worktreeId: 'wt-posix', filePath: '/repo/a/b/file.txt', requestId: 1 } + try { + useAppStore.setState({ pendingExplorerReveal: pending }) + clearStalePendingReveal('/repo/a\\b') + expect(useAppStore.getState().pendingExplorerReveal).toEqual(pending) + clearStalePendingReveal('/repo/a/b') + expect(useAppStore.getState().pendingExplorerReveal).toBeNull() + } finally { + useAppStore.setState({ pendingExplorerReveal: previous }) + } + }) + + it.each(['/repo', '/ssh/repo\\'])( + 'preserves POSIX backslashes in watched roots, filenames and parents under %s', + (root) => { + expect(normalizeExplorerAbsolutePath(`${root}/`)).toBe(root) + expect(canonicalizeFileExplorerWatchPath(root, `${root}/a\\b.txt`)).toBe(`${root}/a\\b.txt`) + expect(getExternalFileChangeRelativePath(root, `${root}/a\\b.txt`, false)).toBe('a\\b.txt') + expect(parentDirForWatchPath(`${root}/a\\b.txt`)).toBe(root) + expect(parentDirForWatchPath(`${root}/a\\/b.txt`)).toBe(`${root}/a\\`) + } + ) + + it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'retains Windows watcher separator semantics under %s', + (root) => { + expect(normalizeExplorerAbsolutePath(`${root}\\`)).toBe(root) + expect(getExternalFileChangeRelativePath(root, `${root}\\a\\b.txt`, false)).toBe('a/b.txt') + expect(parentDirForWatchPath(`${root}\\a\\b.txt`)).toBe(`${root}\\a`) + } + ) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts index 4066bca2d81..9988d9a9387 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-path.ts @@ -1,11 +1,14 @@ import { joinPath, dirname, normalizeRelativePath } from '@/lib/path' import { + isWindowsAbsolutePathLike, normalizeRuntimePathForComparison, relativePathInsideRoot } from '../../../../shared/cross-platform-path' export function normalizeExplorerAbsolutePath(path: string): string { - return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) ? path : path.replace(/[\\/]+$/, '') + return path === '/' || /^[A-Za-z]:[\\/]$/.test(path) + ? path + : path.replace(isWindowsAbsolutePathLike(path) ? /[\\/]+$/ : /\/+$/, '') } export function getExternalFileChangeRelativePath( @@ -23,7 +26,7 @@ export function getExternalFileChangeRelativePath( } // Why: EditorPanel reloads tabs only from a worktree-relative path, not the watcher's absolute one; normalize or contents go stale. - return normalizeRelativePath(relativePath) + return normalizeRelativePath(relativePath, worktreePath) } export function canonicalizeFileExplorerWatchPath( @@ -87,6 +90,10 @@ export function resolveCachedDirPath( } export function parentDirForWatchPath(normalizedPath: string): string { + if (!isWindowsAbsolutePathLike(normalizedPath)) { + const separator = normalizedPath.lastIndexOf('/') + return separator === -1 ? '.' : normalizedPath.slice(0, separator) || '/' + } const parentPath = dirname(normalizedPath) if (/^[A-Za-z]:$/.test(parentPath)) { return `${parentPath}${normalizedPath.includes('\\') ? '\\' : '/'}` diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts index 0f151c54295..6072cdb201f 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watcher-reconcile.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from 'react' import type { DirCache } from './file-explorer-types' -import { normalizeAbsolutePath, isPathEqualOrDescendant } from './file-explorer-paths' +import { isPathEqualOrDescendant } from './file-explorer-paths' import { useAppStore } from '@/store' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' @@ -103,14 +103,10 @@ export function purgeExpandedDirsSubtrees( // would cause the reveal logic to expand stale ancestor directories. export function clearStalePendingReveal(deletedPath: string): void { - const normalized = normalizeAbsolutePath(deletedPath) useAppStore.setState((state) => { if ( state.pendingExplorerReveal && - isPathEqualOrDescendant( - normalizeAbsolutePath(state.pendingExplorerReveal.filePath), - normalized - ) + isPathEqualOrDescendant(state.pendingExplorerReveal.filePath, deletedPath) ) { return { pendingExplorerReveal: null } } diff --git a/src/renderer/src/components/right-sidebar/path-tree.ts b/src/renderer/src/components/right-sidebar/path-tree.ts index 133d2ec50a7..c983d4d4859 100644 --- a/src/renderer/src/components/right-sidebar/path-tree.ts +++ b/src/renderer/src/components/right-sidebar/path-tree.ts @@ -1,3 +1,5 @@ -export function splitPathSegments(path: string): string[] { - return path.split(/[\\/]+/).filter(Boolean) +import { normalizeRelativePath } from '@/lib/path' + +export function splitPathSegments(path: string, rootPath?: string | null): string[] { + return normalizeRelativePath(path, rootPath).split('/').filter(Boolean) } diff --git a/src/renderer/src/components/right-sidebar/status-display.ts b/src/renderer/src/components/right-sidebar/status-display.ts index 2af97edd531..978d4cc98aa 100644 --- a/src/renderer/src/components/right-sidebar/status-display.ts +++ b/src/renderer/src/components/right-sidebar/status-display.ts @@ -124,14 +124,17 @@ export function shouldShowIgnoredDecoration( return !nodeStatus && isPathIgnored(ignored, relativePath) } -export function buildIgnoredSet(ignoredPaths: readonly string[] | undefined): Set { +export function buildIgnoredSet( + ignoredPaths: readonly string[] | undefined, + rootPath?: string | null +): Set { const set = new Set() if (!ignoredPaths) { return set } for (const rawPath of ignoredPaths) { - const trimmed = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath - set.add(normalizeRelativePath(trimmed)) + const path = normalizeRelativePath(rawPath, rootPath) + set.add(path.endsWith('/') ? path.slice(0, -1) : path) } return set } diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts index afefa839b41..8c0e039b187 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts @@ -49,7 +49,7 @@ export function getFileExplorerIgnoredQueryRelativePaths( return } for (const row of cached.children) { - if (!showDotfiles && isDotfileRelativePath(row.relativePath)) { + if (!showDotfiles && isDotfileRelativePath(row.relativePath, worktreePath)) { continue } relativePaths.push(row.relativePath) @@ -88,7 +88,7 @@ export function createVisibleFileExplorerRowProjection( } const shouldHideRow = (row: TreeNode): boolean => { - if (!options.showDotfiles && isDotfileRelativePath(row.relativePath)) { + if (!options.showDotfiles && isDotfileRelativePath(row.relativePath, worktreePath)) { return true } return !options.showGitIgnoredFiles && isPathIgnored(options.ignoredSet, row.relativePath) @@ -179,7 +179,11 @@ export function useFileExplorerVisibleRowProjection( () => activeRepoSupportsGit ? nameFilter - ? getFileExplorerNameFilterIgnoredQueryRelativePaths(nameFilter, showDotfiles) + ? getFileExplorerNameFilterIgnoredQueryRelativePaths( + nameFilter, + showDotfiles, + worktreePath + ) : getFileExplorerIgnoredQueryRelativePaths( { dirCache, expanded, worktreePath, displayRootPath }, showDotfiles @@ -211,7 +215,10 @@ export function useFileExplorerVisibleRowProjection( shouldDebounceIgnoredQuery, worktreePath }) - const ignoredSet = useMemo(() => buildIgnoredSet(effectiveIgnoredPaths), [effectiveIgnoredPaths]) + const ignoredSet = useMemo( + () => buildIgnoredSet(effectiveIgnoredPaths, worktreePath), + [effectiveIgnoredPaths, worktreePath] + ) const rowProjection = useMemo( () => createVisibleFileExplorerRowProjection( diff --git a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts index e66e647c26f..a1ff15bf55b 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts @@ -21,6 +21,7 @@ export type FileSearchPanelModel = { filtersProps: SearchFiltersProps resultsProps: { results: SearchResult | null + error?: string | null hasCommittedResults: boolean query: string loading: boolean @@ -132,7 +133,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch useEffect(() => { if (!worktreePath) { cancelPendingSearch() - updateActiveSearchState({ results: null, resultOwner: null }) + updateActiveSearchState({ results: null, resultOwner: null, error: null }) } }, [worktreePath, cancelPendingSearch, updateActiveSearchState]) @@ -281,6 +282,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch }, resultsProps: { results: deferredSearchResults.results, + error: searchState?.error, hasCommittedResults: fileSearchResults !== null, query: fileSearchQuery, loading: fileSearchLoading, diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx index c68f5effa4e..5615a0e4ab8 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx @@ -163,4 +163,53 @@ describe('useFileSearchRunner result ownership', () => { resultOwner: { worktreeId, runtimeEnvironmentId: null } }) }) + + it('shows an active failure and clears it when the next search succeeds or is empty', async () => { + const worktreeId = 'missing-repo::/repo' + const { hook, updates } = renderSearchRunner( + { settings: {}, repos: [], worktreesByRepo: {}, fileSearchStateByWorktree: {} }, + worktreeId + ) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + mocks.searchRuntimeFiles.mockRejectedValueOnce( + new Error("Error invoking remote method 'search': Error: regex parse error\nUnclosed group") + ) + await finishSearch(hook.result.current.executeSearch) + expect(Object.assign({}, ...updates)).toMatchObject({ + error: 'regex parse error\nUnclosed group', + results: null, + loading: false + }) + + await finishSearch(hook.result.current.executeSearch) + expect(Object.assign({}, ...updates)).toMatchObject({ error: null, results: RESULTS }) + act(() => hook.result.current.executeSearch('')) + expect(Object.assign({}, ...updates)).toMatchObject({ error: null, results: null }) + log.mockRestore() + }) + + it('ignores an older rejection after a newer search has succeeded', async () => { + const { hook, updates } = renderSearchRunner( + { settings: {}, repos: [], worktreesByRepo: {}, fileSearchStateByWorktree: {} }, + 'missing-repo::/repo' + ) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + let rejectOldSearch: (reason: Error) => void = () => {} + mocks.searchRuntimeFiles.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectOldSearch = reject + }) + ) + await finishSearch(hook.result.current.executeSearch) + await finishSearch(hook.result.current.executeSearch) + await act(async () => rejectOldSearch(new Error('old failure'))) + expect(Object.assign({}, ...updates)).toMatchObject({ + error: null, + results: RESULTS, + loading: false + }) + expect(updates.some((update) => update.error === 'old failure')).toBe(false) + log.mockRestore() + }) }) diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts index 7950a86114f..24d44d7a3aa 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts @@ -1,3 +1,5 @@ +import { readIpcErrorDetail } from '@/lib/ipc-error' +import { translate } from '@/i18n/i18n' import { useCallback, useEffect, useRef } from 'react' import { getConnectionId } from '@/lib/connection-context' import { @@ -17,6 +19,7 @@ const SEARCH_DEBOUNCE_MS = 300 const SEARCH_MAX_RESULTS = 2000 type UpdateSearchState = (updates: { + error?: string | null loading?: boolean results?: SearchResult | null resultOwner?: FileSearchResultOwner | null @@ -54,6 +57,7 @@ export function useFileSearchRunner({ (query: string) => { latestSearchIdRef.current += 1 const searchId = latestSearchIdRef.current + updateActiveSearchState({ error: null }) if (searchTimerRef.current) { clearTimeout(searchTimerRef.current) @@ -138,7 +142,10 @@ export function useFileSearchRunner({ console.error('Search failed:', err) if (latestSearchIdRef.current === searchId) { updateActiveSearchState({ - results: { files: [], totalMatches: 0, truncated: false }, + results: null, + error: + readIpcErrorDetail(err) ?? + translate('fileSearch.failed', 'Search failed. Try again.'), resultOwner }) } diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx index f569c9f5114..996b15eb88e 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '../../store' import { GeneralUpdateSettingsSection } from './GeneralUpdateSettingsSection' @@ -7,7 +7,10 @@ import { GeneralUpdateSettingsSection } from './GeneralUpdateSettingsSection' vi.mock('./GeneralRemoteServerUpdates', () => ({ GeneralRemoteServerUpdates: () => null })) vi.mock('./ReleaseChannelSection', () => ({ ReleaseChannelSection: () => null })) +const quitAndInstall = vi.fn() + beforeEach(() => { + quitAndInstall.mockReset().mockResolvedValue(undefined) useAppStore.setState({ updateStatus: { state: 'available', version: '1.4.200', changelog: null } }) @@ -17,6 +20,7 @@ beforeEach(() => { updater: { check: vi.fn(), download: vi.fn(), + quitAndInstall, getVersion: vi.fn().mockResolvedValue('1.4.199') } } @@ -35,3 +39,36 @@ it('describes the available action as a download', () => { expect(screen.getByText(/is available\. Click "Download Update" to download it\./)).toBeTruthy() expect(screen.queryByText(/download and install it/)).toBeNull() }) + +it('retries installation from the settings panel when a staged update is blocked', () => { + useAppStore.setState({ + updateStatus: { + state: 'error', + version: '1.4.200', + message: 'Close the other Orca instances before installing this update.', + retryAction: 'install' + } + }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })) + expect(quitAndInstall).toHaveBeenCalledTimes(1) +}) + +it.each([undefined, false] as const)( + 'does not offer install retry without an install action or when retry is refused (%s)', + (retryable) => { + useAppStore.setState({ + updateStatus: { + state: 'error', + version: '1.4.200', + message: 'Update failed.', + ...(retryable === false ? { retryAction: 'install', retryable } : {}) + } + }) + render() + + expect(screen.queryByRole('button', { name: 'Try Again' })).toBeNull() + expect(quitAndInstall).not.toHaveBeenCalled() + } +) diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 1e59c200fca..fb4d5171f1b 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -138,6 +138,13 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { )} {updateStatus.version}) + ) : updateStatus.state === 'error' && + updateStatus.retryAction === 'install' && + updateStatus.retryable !== false ? ( + ) : updateStatus.state === 'downloaded' ? ( ', + attributes: {}, + accessibility: { + role: 'button', + accessibleName: 'Submit', + ariaLabel: null, + ariaLabelledBy: null + }, + rectViewport: { x: 0, y: 0, width: 100, height: 40 }, + rectPage: { x: 0, y: 0, width: 100, height: 40 }, + computedStyles: { + display: 'inline-flex', + position: 'static', + width: '100px', + height: '40px', + margin: '0px', + padding: '0px', + color: 'rgb(0, 0, 0)', + backgroundColor: 'rgba(0, 0, 0, 0)', + border: '0px none', + borderRadius: '0px', + fontFamily: 'Geist', + fontSize: '14px', + fontWeight: '400', + lineHeight: '20px', + textAlign: 'center', + zIndex: 'auto' + } + }, + nearbyText: [], + ancestorPath: [], + screenshot: null + } + } +} diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index b961f54fbad..9cdf05e17ed 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types' +import { makeAnnotation } from './browser-annotation-test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { createBrowserMockApi, @@ -45,65 +46,6 @@ function seedUnifiedBrowserTab( }) } -function makeAnnotation(pageId: string, id = 'annotation-1'): BrowserPageAnnotation { - return { - id, - browserPageId: pageId, - comment: 'Fix this button', - intent: 'fix', - priority: 'important', - createdAt: '2026-05-15T00:00:00.000Z', - payload: { - page: { - sanitizedUrl: 'https://example.com', - title: 'Example', - viewportWidth: 1280, - viewportHeight: 720, - scrollX: 0, - scrollY: 0, - devicePixelRatio: 1, - capturedAt: '2026-05-15T00:00:00.000Z' - }, - target: { - tagName: 'button', - selector: 'button', - textSnippet: 'Submit', - htmlSnippet: '', - attributes: {}, - accessibility: { - role: 'button', - accessibleName: 'Submit', - ariaLabel: null, - ariaLabelledBy: null - }, - rectViewport: { x: 0, y: 0, width: 100, height: 40 }, - rectPage: { x: 0, y: 0, width: 100, height: 40 }, - computedStyles: { - display: 'inline-flex', - position: 'static', - width: '100px', - height: '40px', - margin: '0px', - padding: '0px', - color: 'rgb(0, 0, 0)', - backgroundColor: 'rgba(0, 0, 0, 0)', - border: '0px none', - borderRadius: '0px', - fontFamily: 'Geist', - fontSize: '14px', - fontWeight: '400', - lineHeight: '20px', - textAlign: 'center', - zIndex: 'auto' - } - }, - nearbyText: [], - ancestorPath: [], - screenshot: null - } - } -} - describe('createBrowserSlice annotations', () => { it('announces the store-selected browser page before its guest is destroyed', () => { const store = createTestStore() @@ -164,7 +106,7 @@ describe('createBrowserSlice annotations', () => { }) }) - it('clears page annotations when the browser page URL changes', () => { + it('retains saved page annotations while retiring geometry when the URL changes', () => { const store = createTestStore() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') const pageId = tab.activePageId @@ -175,9 +117,13 @@ describe('createBrowserSlice annotations', () => { store.getState().addBrowserPageAnnotation(makeAnnotation(pageId)) expect(store.getState().browserAnnotationsByPageId[pageId]).toHaveLength(1) + const saved = store.getState().browserAnnotationsByPageId[pageId] store.getState().setBrowserPageUrl(pageId, 'https://example.com/next') + store.getState().setBrowserPageUrl(pageId, 'https://example.com') - expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() + expect(store.getState().browserAnnotationsByPageId[pageId]).toBe(saved) + expect(saved?.[0]?.payload.page.sanitizedUrl).toBe('https://example.com') + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() }) it('can commit a navigation URL without hiding an active recovery error', () => { diff --git a/src/renderer/src/store/slices/browser/browser-close-actions.ts b/src/renderer/src/store/slices/browser/browser-close-actions.ts index 33b9c4bb681..b2e0eac483d 100644 --- a/src/renderer/src/store/slices/browser/browser-close-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -55,11 +55,13 @@ export function createBrowserCloseActions( const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } delete nextBrowserPagesByWorkspace[tabId] const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } for (const page of closedPages) { delete nextBrowserAnnotationsByPageId[page.id] + delete nextBrowserAnnotationMarkerIdsByPageId[page.id] delete nextBrowserCertificateFailuresByPageId[page.id] } docPageIdsToRelease = closedPages.filter((page) => page.docLocation).map((page) => page.id) @@ -168,7 +170,8 @@ export function createBrowserCloseActions( recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId } }) diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts index 13beee05872..defeee551a6 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -207,6 +207,7 @@ export function createBrowserHydrationActions( buildRestoredRemoteBrowserPageHandles(browserPagesByWorkspace), browserCertificateFailuresByPageId: {}, browserAnnotationsByPageId: {}, + browserAnnotationMarkerIdsByPageId: {}, browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []), workspaceDocHistory: normalizeWorkspaceDocHistoryEntries( session.workspaceDocHistory ?? [] diff --git a/src/renderer/src/store/slices/browser/browser-page-annotation.ts b/src/renderer/src/store/slices/browser/browser-page-annotation.ts index c8be505ae2d..47b83f3ce20 100644 --- a/src/renderer/src/store/slices/browser/browser-page-annotation.ts +++ b/src/renderer/src/store/slices/browser/browser-page-annotation.ts @@ -11,8 +11,31 @@ export function sanitizeBrowserPageAnnotation( : annotation.comment, payload: { ...annotation.payload, - // Why: annotations persist to disk; null the transient screenshot to avoid retaining megabytes per note. + // Saved notes must not retain transient screenshot bytes. screenshot: null } } } + +export function retainBrowserAnnotationMarkerIds( + markersByPageId: Record, + pageId: string, + annotations: readonly BrowserPageAnnotation[], + addedId?: string +): Record { + const existingIds = markersByPageId[pageId] ?? [] + const retainedIds = new Set(annotations.map((annotation) => annotation.id)) + const nextIds = [...new Set(addedId ? [...existingIds, addedId] : existingIds)].filter((id) => + retainedIds.has(id) + ) + if (nextIds.length === existingIds.length && nextIds.every((id, i) => id === existingIds[i])) { + return markersByPageId + } + const nextByPageId = { ...markersByPageId } + if (nextIds.length > 0) { + nextByPageId[pageId] = nextIds + } else { + delete nextByPageId[pageId] + } + return nextByPageId +} diff --git a/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts index 53156c04dd1..11a78f5f17b 100644 --- a/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts @@ -59,7 +59,9 @@ export function createBrowserPageConversionActions( const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } delete nextBrowserAnnotationsByPageId[plan.oldPage.id] + delete nextBrowserAnnotationMarkerIdsByPageId[plan.oldPage.id] const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] return { @@ -85,7 +87,8 @@ export function createBrowserPageConversionActions( ), remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId } }) // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does diff --git a/src/renderer/src/store/slices/browser/browser-page-create-actions.ts b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts index d5562b3e59a..bb31d191a2c 100644 --- a/src/renderer/src/store/slices/browser/browser-page-create-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts @@ -140,7 +140,9 @@ export function createBrowserPageCreateActions( } delete nextRemoteBrowserPageHandlesByPageId[pageId] const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } delete nextBrowserAnnotationsByPageId[pageId] + delete nextBrowserAnnotationMarkerIdsByPageId[pageId] const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } @@ -178,7 +180,8 @@ export function createBrowserPageCreateActions( ), remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId } }) diff --git a/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts b/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts index d5c1675621c..48ef5426846 100644 --- a/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts @@ -6,7 +6,11 @@ import type { RemoteBrowserPageHandle } from './browser-slice-contract' import { findPage, findWorkspace } from '../browser-page-records' -import { sanitizeBrowserPageAnnotation } from './browser-page-annotation' +import { + retainBrowserAnnotationMarkerIds, + sanitizeBrowserPageAnnotation +} from './browser-page-annotation' +import { omitRecordKeys } from '../worktrees/teardown/record-key-omission' export function createBrowserPageMetadataActions( set: BrowserSliceSet, @@ -20,6 +24,7 @@ export function createBrowserPageMetadataActions( | 'updateBrowserPageAnnotation' | 'deleteBrowserPageAnnotation' | 'clearBrowserPageAnnotations' + | 'invalidateBrowserPageAnnotationGeometry' | 'removeDeliveredBrowserPageAnnotations' > { return { @@ -81,7 +86,13 @@ export function createBrowserPageMetadataActions( browserAnnotationsByPageId: { ...s.browserAnnotationsByPageId, [annotation.browserPageId]: next - } + }, + browserAnnotationMarkerIdsByPageId: retainBrowserAnnotationMarkerIds( + s.browserAnnotationMarkerIdsByPageId, + annotation.browserPageId, + next, + annotation.id + ) } }), @@ -116,17 +127,35 @@ export function createBrowserPageMetadataActions( } else { delete nextByPageId[pageId] } - return { browserAnnotationsByPageId: nextByPageId } + return { + browserAnnotationsByPageId: nextByPageId, + browserAnnotationMarkerIdsByPageId: retainBrowserAnnotationMarkerIds( + s.browserAnnotationMarkerIdsByPageId, + pageId, + next + ) + } }), clearBrowserPageAnnotations: (pageId) => set((s) => { - if (!s.browserAnnotationsByPageId[pageId]?.length) { + const notes = omitRecordKeys(s.browserAnnotationsByPageId, [pageId]) + const markers = omitRecordKeys(s.browserAnnotationMarkerIdsByPageId, [pageId]) + if ( + notes === s.browserAnnotationsByPageId && + markers === s.browserAnnotationMarkerIdsByPageId + ) { return s } - const nextByPageId = { ...s.browserAnnotationsByPageId } - delete nextByPageId[pageId] - return { browserAnnotationsByPageId: nextByPageId } + return { browserAnnotationsByPageId: notes, browserAnnotationMarkerIdsByPageId: markers } + }), + + invalidateBrowserPageAnnotationGeometry: (pageId) => + set((s) => { + const markers = omitRecordKeys(s.browserAnnotationMarkerIdsByPageId, [pageId]) + return markers === s.browserAnnotationMarkerIdsByPageId + ? s + : { browserAnnotationMarkerIdsByPageId: markers } }), // Identity matching preserves edits and additions made during delivery. @@ -144,7 +173,14 @@ export function createBrowserPageMetadataActions( } else { delete nextByPageId[pageId] } - return { browserAnnotationsByPageId: nextByPageId } + return { + browserAnnotationsByPageId: nextByPageId, + browserAnnotationMarkerIdsByPageId: retainBrowserAnnotationMarkerIds( + s.browserAnnotationMarkerIdsByPageId, + pageId, + remaining + ) + } }) } } diff --git a/src/renderer/src/store/slices/browser/browser-page-state-actions.ts b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts index 83aa3c23673..2ad9e6c3387 100644 --- a/src/renderer/src/store/slices/browser/browser-page-state-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts @@ -187,8 +187,8 @@ export function createBrowserPageStateActions( // and a document's url is blank by construction. A grant committed here would reach // persistence, the publish boundary and the address bar, exactly as at the other two doors. const nextPageUrl = page.docLocation ? ORCA_BROWSER_BLANK_URL : nextUrl - // Why: annotations point at DOM coords of the loaded document; a real URL change invalidates those markers. - const shouldClearAnnotations = normalizeUrl(page.url) !== nextPageUrl + // Saved feedback survives navigation; its old document coordinates do not. + const shouldInvalidateGeometry = normalizeUrl(page.url) !== nextPageUrl const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => entry.id === pageId ? { @@ -203,11 +203,11 @@ export function createBrowserPageStateActions( : entry ) const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) - const nextBrowserAnnotationsByPageId = shouldClearAnnotations - ? { ...s.browserAnnotationsByPageId } - : s.browserAnnotationsByPageId - if (shouldClearAnnotations) { - delete nextBrowserAnnotationsByPageId[pageId] + const nextBrowserAnnotationMarkerIdsByPageId = shouldInvalidateGeometry + ? { ...s.browserAnnotationMarkerIdsByPageId } + : s.browserAnnotationMarkerIdsByPageId + if (shouldInvalidateGeometry) { + delete nextBrowserAnnotationMarkerIdsByPageId[pageId] } return { browserPagesByWorkspace: { @@ -220,8 +220,8 @@ export function createBrowserPageStateActions( (tab) => (tab.id === workspace.id ? nextWorkspace : tab) ) }, - ...(shouldClearAnnotations - ? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } + ...(shouldInvalidateGeometry + ? { browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId } : {}) } }) diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts index 3cbeba14d98..9baa4c1bc40 100644 --- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -112,6 +112,7 @@ export type BrowserSlice = { browserPagesByWorkspace: Record browserCertificateFailuresByPageId: Record browserAnnotationsByPageId: Record + browserAnnotationMarkerIdsByPageId: Record remoteBrowserPageHandlesByPageId: Record /** * Closes of client-hosted pages their owning runtime never heard, keyed by environment. @@ -196,6 +197,7 @@ export type BrowserSlice = { ) => void deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void clearBrowserPageAnnotations: (pageId: string) => void + invalidateBrowserPageAnnotationGeometry: (pageId: string) => void removeDeliveredBrowserPageAnnotations: ( pageId: string, deliveredAnnotations: readonly BrowserPageAnnotation[] diff --git a/src/renderer/src/store/slices/browser/create-browser-slice.ts b/src/renderer/src/store/slices/browser/create-browser-slice.ts index 13519fc5b3a..a25310694d7 100644 --- a/src/renderer/src/store/slices/browser/create-browser-slice.ts +++ b/src/renderer/src/store/slices/browser/create-browser-slice.ts @@ -21,6 +21,7 @@ export const createBrowserSlice: StateCreator = browserPagesByWorkspace: {}, browserCertificateFailuresByPageId: {}, browserAnnotationsByPageId: {}, + browserAnnotationMarkerIdsByPageId: {}, remoteBrowserPageHandlesByPageId: {}, clientHostedBrowserCloseIntentsByEnvironment: {}, activeBrowserTabId: null, diff --git a/src/renderer/src/store/slices/editor/actions/file-search-actions.ts b/src/renderer/src/store/slices/editor/actions/file-search-actions.ts index d68a7618298..4e8ef519ceb 100644 --- a/src/renderer/src/store/slices/editor/actions/file-search-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/file-search-actions.ts @@ -39,6 +39,7 @@ export function createFileSearchActions( results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set(), seedRequestId: (current.seedRequestId ?? 0) + 1 } @@ -57,6 +58,7 @@ export function createFileSearchActions( results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set(), seedRequestId: (current.seedRequestId ?? 0) + 1 } @@ -112,6 +114,7 @@ export function createFileSearchActions( results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set() } } diff --git a/src/renderer/src/store/slices/editor/search/file-search-state.ts b/src/renderer/src/store/slices/editor/search/file-search-state.ts index 063bb379fad..dba481e1f09 100644 --- a/src/renderer/src/store/slices/editor/search/file-search-state.ts +++ b/src/renderer/src/store/slices/editor/search/file-search-state.ts @@ -10,6 +10,7 @@ const DEFAULT_FILE_SEARCH_STATE = { results: null, resultOwner: null, loading: false, + error: null, collapsedFiles: new Set() } satisfies Omit diff --git a/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts b/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts index a59d9c1e5c6..d70c0399ce4 100644 --- a/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts +++ b/src/renderer/src/store/slices/editor/types/file-search-worktree-state.ts @@ -10,6 +10,7 @@ export type FileSearchWorktreeState = { excludePattern: string results: SearchResult | null resultOwner: FileSearchResultOwner | null + error?: string | null loading: boolean collapsedFiles: Set seedRequestId?: number diff --git a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts index 77f997083aa..6fe2a9e068a 100644 --- a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts +++ b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts @@ -111,6 +111,43 @@ describe('createGitHubSlice.patchWorkItem', () => { }) expect(secondPatched).toBe(secondItem) }) + + it('scopes a canonical repository patch within its existing host and account scope', () => { + const store = createTestStore() + const source = githubSourceContext('local', 'repo-1') + const otherAccount = { ...source, projectHostSetupId: 'another-account-setup' } + const canonical: GitHubWorkItem = { + id: 'issue:42', + repoId: 'repo-1', + type: 'issue', + number: 42, + title: 'Fork issue', + state: 'open', + labels: [], + updatedAt: '', + author: null, + url: 'https://github.com/fork/widgets/issues/42' + } + const upstream = { ...canonical, url: 'https://github.com/upstream/widgets/issues/42' } + const enterprise = { ...canonical, url: 'https://ghe.example:8443/fork/widgets/issues/42' } + const sourceKey = workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(source)) + const otherKey = workItemsCacheKey('repo-1', 20, '', getTaskSourceCacheScope(otherAccount)) + store.setState({ + workItemsCache: { + [sourceKey]: { data: [upstream, enterprise, canonical], fetchedAt: 1 }, + [otherKey]: { data: [canonical], fetchedAt: 1 } + } + }) + store.getState().patchWorkItem(canonical.id, { labels: ['bug'] }, canonical.repoId, { + sourceContext: source, + ownerRepo: { owner: 'FORK', repo: 'Widgets', host: ' GitHub.com ' } + }) + const rows = store.getState().workItemsCache[sourceKey]?.data + expect(rows?.[0]).toBe(upstream) + expect(rows?.[1]).toBe(enterprise) + expect(rows?.[2].labels).toEqual(['bug']) + expect(store.getState().workItemsCache[otherKey]?.data?.[0]).toBe(canonical) + }) }) describe('createGitHubSlice.fetchWorkItems cache identity', () => { diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts index dcdb4edd370..da2eb9e19b4 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -1,13 +1,13 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { TuiAgent } from '../../../../shared/tui-agent' +import type { TerminalAgent } from '../../../../shared/terminal-agent' /** 'launch-record': the daemon's launch identity on reattach, which can outlive the process. */ export type PaneForegroundAgentEvidence = 'process-read' | 'launch-record' export type PaneForegroundAgentEntry = { /** Recognized agent process in the pane's foreground; null when unknown. */ - agent: TuiAgent | null + agent: TerminalAgent | null /** Where `agent` came from. Only 'process-read' is a live read; absent counts as unconfirmed, * so a writer that omits it cannot make a sidebar row look process-backed. */ agentEvidence?: PaneForegroundAgentEvidence diff --git a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts index 180f96e427a..a69a18999c5 100644 --- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts +++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts @@ -251,6 +251,19 @@ describe('createUISlice browser import hint dismissal', () => { }) }) +describe('createUISlice Codex shared-settings notice', () => { + it('counts as seen until hydration, then follows the persisted flag', () => { + const store = createUIStore() + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(true) + + store.getState().hydratePersistedUI(makePersistedUI({})) + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(false) + + store.getState().hydratePersistedUI(makePersistedUI({ codexSharedSettingsNoticeSeen: true })) + expect(store.getState().codexSharedSettingsNoticeSeen).toBe(true) + }) +}) + describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => { it('restores the armed notice from persisted UI', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts index f964d6e048a..6ec88e1e4f3 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts @@ -116,4 +116,6 @@ export type UISliceContextual = { dismissUsageEmptyState: () => void codexTerminalServerIsolationNoticeSeen: boolean markCodexTerminalServerIsolationNoticeSeen: () => void + codexSharedSettingsNoticeSeen: boolean + markCodexSharedSettingsNoticeSeen: () => void } diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 74f45118b09..dea5b0c2e80 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -244,6 +244,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, codexTerminalServerIsolationNoticeSeen: ui.codexTerminalServerIsolationNoticeSeen === true, + codexSharedSettingsNoticeSeen: ui.codexSharedSettingsNoticeSeen === true, ...hydrateAgentReadState(ui), workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( ui.workspaceCleanup?.dismissals diff --git a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts index dabf69bb29d..1e1c25573a2 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts @@ -148,6 +148,15 @@ export function createUiTrustActions(set: UISliceSet, _get: UISliceGet): Partial } window.api.ui.set({ codexTerminalServerIsolationNoticeSeen: true }).catch(console.error) return { codexTerminalServerIsolationNoticeSeen: true } + }), + codexSharedSettingsNoticeSeen: true, + markCodexSharedSettingsNoticeSeen: () => + set((s) => { + if (s.codexSharedSettingsNoticeSeen) { + return s + } + window.api.ui.set({ codexSharedSettingsNoticeSeen: true }).catch(console.error) + return { codexSharedSettingsNoticeSeen: true } }) } } diff --git a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts index 0c553518ef0..d882e98770b 100644 --- a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts @@ -290,6 +290,7 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously [WS2]: [makePage(P2, WS2, WT2)] }, browserAnnotationsByPageId: { [P1]: [], [P2]: [] }, + browserAnnotationMarkerIdsByPageId: { [P1]: ['note-1'], [P2]: ['note-2'] }, remoteBrowserPageHandlesByPageId: { [P1]: { environmentId: 'env-1', remotePageId: 'r-1' }, [P2]: { environmentId: 'env-2', remotePageId: 'r-2' } @@ -308,6 +309,7 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously const s = store.getState() // Removed worktree's workspace + page entries are gone. expect(s.browserAnnotationsByPageId[P1]).toBeUndefined() + expect(s.browserAnnotationMarkerIdsByPageId[P1]).toBeUndefined() expect(s.remoteBrowserPageHandlesByPageId[P1]).toBeUndefined() expect(s.pendingAddressBarFocusByPageId[P1]).toBeUndefined() expect(s.pendingAddressBarFocusByTabId[WS1]).toBeUndefined() @@ -315,6 +317,7 @@ describe('worktree removal evicts the per-worktree + per-page maps it previously expect(s.recentlyClosedBrowserPagesByWorkspace[WS1]).toBeUndefined() // Surviving worktree's entries remain (guard over-eviction). expect(s.browserAnnotationsByPageId[P2]).toBeDefined() + expect(s.browserAnnotationMarkerIdsByPageId[P2]).toEqual(['note-2']) expect(s.remoteBrowserPageHandlesByPageId[P2]).toBeDefined() expect(s.pendingAddressBarFocusByPageId[P2]).toBe(true) expect(s.pendingAddressBarFocusByTabId[WS2]).toBe(true) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index ffa3cb6076d..26f86df2810 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -195,6 +195,7 @@ export function buildWorktreePurgeState( activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree), // Why: keyed by page/workspace id, only cleaned by closeBrowserTab on the single-removal path; the bulk reconcile missed them, orphaning an entry per page of externally-removed worktrees. browserAnnotationsByPageId: omitByPageId(s.browserAnnotationsByPageId), + browserAnnotationMarkerIdsByPageId: omitByPageId(s.browserAnnotationMarkerIdsByPageId), remoteBrowserPageHandlesByPageId: omitByPageId(s.remoteBrowserPageHandlesByPageId), pendingAddressBarFocusByPageId: omitByPageId(s.pendingAddressBarFocusByPageId), // createBrowserTab writes both the workspace id and the page id into this map. diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index b158cf86898..ad5c83bfb90 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -3,10 +3,11 @@ 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 { isDsbHeadlessOneShotCommand } from './dsb-headless-command' import { isZCodeHeadlessOneShotCommand } from './zcode-headless-command' import { isPrimeAgentHeadlessOneShotCommand } from './prime-agent-headless-command' import { isPrintModeHeadlessOneShotCommand } from './print-mode-headless-command' -import type { TuiAgent } from './tui-agent' +import type { TerminalAgent } from './terminal-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 @@ -15,7 +16,7 @@ import type { TuiAgent } from './tui-agent' // 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> + Record boolean> > = { qoder: isQoderHeadlessCommand, 'qoder-cn': isQoderHeadlessCommand, @@ -26,14 +27,18 @@ const HEADLESS_ONE_SHOT_MATCHERS: Partial< ante: isAnteHeadlessOneShotCommand, muse: isMuseHeadlessOneShotCommand, zcode: isZCodeHeadlessOneShotCommand, - dsh: isDshNonInteractiveCommand + dsh: isDshNonInteractiveCommand, + dsb: isDsbHeadlessOneShotCommand } -export function isHeadlessOneShotAgentCommand(agent: TuiAgent, tokens: readonly string[]): boolean { +export function isHeadlessOneShotAgentCommand( + agent: TerminalAgent, + tokens: readonly string[] +): boolean { return HEADLESS_ONE_SHOT_MATCHERS[agent]?.(tokens) ?? false } -type AgentCommandRecognition = { agent: TuiAgent } | null +type AgentCommandRecognition = { agent: TerminalAgent } | null export function filterHeadlessOneShotAgentCommand( recognition: T, diff --git a/src/shared/agent-node-package-entrypoints.ts b/src/shared/agent-node-package-entrypoints.ts index 7f8f9abd7cb..43562646609 100644 --- a/src/shared/agent-node-package-entrypoints.ts +++ b/src/shared/agent-node-package-entrypoints.ts @@ -13,5 +13,8 @@ export const NODE_PACKAGE_SCRIPT_ENTRYPOINTS: Record 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/'] + 'dsh-tui': ['node_modules/@deepseek-harness-tui/dsh-tui/'], + // Why: the dsb npm shim executes a Node script under its own package. + dsb: ['node_modules/@innocarpe/deepseek-build/'], + 'deepseek-build': ['node_modules/@innocarpe/deepseek-build/'] } diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts index 3375edf2c08..3c275cb2d69 100644 --- a/src/shared/agent-process-recognition.test.ts +++ b/src/shared/agent-process-recognition.test.ts @@ -11,6 +11,56 @@ import { } from './agent-process-recognition' describe('agent process recognition', () => { + it.each([ + '/usr/local/bin/dsb', + '/usr/bin/deepseek-build', + 'C:\\Users\\dev\\bin\\dsb.cmd', + 'C:\\Users\\dev\\bin\\deepseek-build-agent.exe' + ])('recognizes manually started DeepSeek Build at %s', (processName) => { + expect(recognizeAgentProcess(processName)?.agent).toBe('dsb') + }) + + it('recognizes DeepSeek Build by binary name and its npm shim', () => { + expect(recognizeAgentProcess('dsb')).toEqual({ agent: 'dsb', processName: 'dsb' }) + expect(recognizeAgentProcess('deepseek-build')).toEqual({ + agent: 'dsb', + processName: 'deepseek-build' + }) + expect(recognizeAgentProcess('deepseek-build-agent')).toEqual({ + agent: 'dsb', + processName: 'deepseek-build-agent' + }) + expect( + recognizeAgentProcessFromCommandLine( + 'node /usr/lib/node_modules/@innocarpe/deepseek-build/npm/bin/dsb.js' + ) + ).toEqual({ agent: 'dsb', processName: 'dsb' }) + expect( + recognizeAgentProcessFromCommandLine( + 'node /usr/lib/node_modules/@innocarpe/deepseek-build/npm/bin/dsb.js run "explain this"' + ) + ).toBeNull() + expect(recognizeAgentProcessFromCommandLine('dsb run "explain this"')).toBeNull() + }) + + it.each([ + '-r ./preload.js', + '--require "./preload path.js"', + '--import ./preload.mjs', + '--loader ./loader.mjs', + '--experimental-loader ./loader.mjs', + '--require=./preload.js', + '--import=./preload.mjs' + ])('excludes a one-shot DSB npm shim after Node options %s', (options) => { + const shim = `node ${options} /usr/lib/node_modules/@innocarpe/deepseek-build/npm/bin/dsb.js` + expect(recognizeAgentProcessFromCommandLine(`${shim} run "task"`)).toBeNull() + expect( + recognizeAgentProcessFromCommandLine(`${shim} run "task"`, { includeHeadlessOneShot: true }) + ?.agent + ).toBe('dsb') + expect(recognizeAgentProcessFromCommandLine(`${shim} agent`)?.agent).toBe('dsb') + }) + it('recognizes packaged Codex foreground process names', () => { expect(recognizeAgentProcess('codex-aarch64-ap')).toEqual({ agent: 'codex', @@ -55,6 +105,13 @@ describe('agent process recognition', () => { } }) + it('recognizes both published DeepSeek Build npm entrypoints and excludes run with cwd', () => { + const shim = '/usr/lib/node_modules/@innocarpe/deepseek-build/npm/bin/deepseek-build.js' + expect(recognizeAgentProcessFromCommandLine(`node ${shim}`)?.agent).toBe('dsb') + expect(recognizeAgentProcessFromCommandLine(`node ${shim} --cwd folder run task`)).toBeNull() + expect(recognizeAgentProcessFromCommandLine('dsb --cwd folder run task')).toBeNull() + }) + it('recognizes the OpenClaude foreground process', () => { expect(recognizeAgentProcess('/usr/local/bin/openclaude')).toEqual({ agent: 'openclaude', diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index 481b5c5baf3..b9a5620f6df 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -1,8 +1,8 @@ -import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from './tui-agent-config' +import { getTuiAgentDetectCommands, isTuiAgent, 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 type { TerminalAgent } from './terminal-agent' import { filterHeadlessOneShotAgentCommand } from './agent-headless-command' import { getFirstCommandToken } from './command-token-scanner' import { @@ -13,7 +13,7 @@ import { } from './agent-command-line-entrypoint' import { isFreshOmpLaunchCommand } from './omp-fresh-launch' -export type RecognizedAgentProcess = { agent: TuiAgent; processName: string } +export type RecognizedAgentProcess = { agent: TerminalAgent; processName: string } const PROCESS_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i const INTERPRETER_SCRIPT_EXTENSION_RE = /\.(?:js|mjs|cjs)$/i @@ -38,13 +38,17 @@ function normalizeProcessName( const FOREGROUND_AGENT_WRAPPER_PROCESS_NAMES = new Set(['node', 'python', 'python3']) const PYTHON_SCRIPT_ENTRYPOINT_DIRECTORIES = ['/bin/', '/scripts/', '/site-packages/'] -const PROCESS_TO_AGENT = new Map() -const AGENT_TYPE_IDS = new Set() +const PROCESS_TO_AGENT = new Map([ + ['dsb', 'dsb'], + ['deepseek-build', 'dsb'], + ['deepseek-build-agent', 'dsb'] +]) +const AGENT_TYPE_IDS = new Set(['dsb']) -for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG) as [ - TuiAgent, - (typeof TUI_AGENT_CONFIG)[TuiAgent] -][]) { +for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG)) { + if (!isTuiAgent(agent)) { + continue + } AGENT_TYPE_IDS.add(agent) for (const candidate of [ config.expectedProcess, @@ -63,7 +67,7 @@ for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG) as [ } } -function agentForNormalizedProcess(normalized: string): TuiAgent | undefined { +function agentForNormalizedProcess(normalized: string): TerminalAgent | undefined { const exact = PROCESS_TO_AGENT.get(normalized) if (exact) { return exact @@ -231,7 +235,7 @@ export function isRecognizedAgentType(agentType: AgentType | null | undefined): return false } return ( - AGENT_TYPE_IDS.has(agentType as TuiAgent) || + AGENT_TYPE_IDS.has(agentType) || agentForNormalizedProcess(normalizeProcessName(agentType)) !== undefined ) } diff --git a/src/shared/agent-prompt-injection.test.ts b/src/shared/agent-prompt-injection.test.ts index 5a70ed8bad5..87b9116b3d7 100644 --- a/src/shared/agent-prompt-injection.test.ts +++ b/src/shared/agent-prompt-injection.test.ts @@ -45,6 +45,16 @@ describe('agent prompt injection bytes', () => { expect(buildAgentPromptPasteBytes('brief', '')).toBe(`${BEGIN}brief${END}`) }) + it('keeps generic prompt behavior for recognition-only agents', () => { + expect(agentPromptTakesLeadLine('dsb')).toBe(agentPromptTakesLeadLine(undefined)) + expect(agentPromptSubmitJoinsPasteFrame('dsb')).toBe(false) + for (const platform of ['darwin', 'linux', 'win32'] as const) { + expect(resolveAgentPromptSubmitDelayForAgent(platform, 'first\nsecond', 'dsb')).toBe( + resolveAgentPromptSubmitDelayForAgent(platform, 'first\nsecond', undefined) + ) + } + }) + it('keeps submit separate from the paste frame', () => { expect(buildAgentPromptPasteBytes('hello')).not.toContain('\r') }) diff --git a/src/shared/agent-prompt-injection.ts b/src/shared/agent-prompt-injection.ts index b085f6a9048..8f1a7fd785d 100644 --- a/src/shared/agent-prompt-injection.ts +++ b/src/shared/agent-prompt-injection.ts @@ -1,7 +1,7 @@ import { countAgentTuiInputLines } from './agent-tui-input-clear' import { iterateTerminalInputChunks, TERMINAL_INPUT_CHUNK_MAX_BYTES } from './terminal-input' -import type { TuiAgent } from './tui-agent' -import { TUI_AGENT_CONFIG } from './tui-agent-config' +import type { TerminalAgent } from './terminal-agent' +import { isTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config' export const AGENT_PROMPT_BRACKETED_PASTE_START = '\x1b[200~' export const AGENT_PROMPT_BRACKETED_PASTE_END = '\x1b[201~' @@ -9,12 +9,12 @@ export const AGENT_PROMPT_SUBMIT = '\r' /** Why unknown agents keep the lead: an unidentified Claude still needs it, while known non-Claude * TUIs get pre-lead bytes because Codex drops typed text that shares the paste's write (STA-8200). */ -export function agentPromptTakesLeadLine(agent: TuiAgent | null | undefined): boolean { - return !agent || TUI_AGENT_CONFIG[agent].pasteNeedsTypedRequest === true +export function agentPromptTakesLeadLine(agent: TerminalAgent | null | undefined): boolean { + return !isTuiAgent(agent) || TUI_AGENT_CONFIG[agent].pasteNeedsTypedRequest === true } /** OMP recognizes a submitted bracketed paste only when Enter shares its PTY write. */ -export function agentPromptSubmitJoinsPasteFrame(agent: TuiAgent | null | undefined): boolean { +export function agentPromptSubmitJoinsPasteFrame(agent: TerminalAgent | null | undefined): boolean { return agent === 'omp' } @@ -92,9 +92,9 @@ export function getAgentPromptSubmitDelayMs( export function resolveAgentPromptSubmitDelayForAgent( platform: NodeJS.Platform, prompt: string, - agent: TuiAgent | null | undefined + agent: TerminalAgent | null | undefined ): number { - const config = agent ? TUI_AGENT_CONFIG[agent] : undefined + const config = isTuiAgent(agent) ? TUI_AGENT_CONFIG[agent] : undefined return getAgentPromptSubmitDelayMs( platform, Buffer.byteLength(buildAgentPromptPasteBytes(prompt), 'utf8'), diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index 770c184b562..a3acf31af4f 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -103,14 +103,7 @@ export function admitAgentSessionMutation(input: { return { decision: 'refused', refusal: mismatch } } if (ledger.decision === 'refused') { - return { - decision: 'refused', - refusal: refuse( - ledger.code, - ledger.details, - `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` - ) - } + return { decision: 'refused', refusal: agentSessionLedgerRefusal(envelope, ledger) } } if (ledger.decision === 'replay') { return { decision: 'replay', row: ledger.row } @@ -125,6 +118,18 @@ export function admitAgentSessionMutation(input: { return { decision: 'admit', row: ledger.row } } +/** What the ledger's own refusal of an operation id answers. */ +export function agentSessionLedgerRefusal( + envelope: Pick, + ledger: Extract +): AgentSessionWireRefusal { + return refuse( + ledger.code, + ledger.details, + `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` + ) +} + /** Why the single admission oracle said no, mapped to what the client can do * about it. The predicate itself is never re-implemented here. */ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { diff --git a/src/shared/agent-session-option-agent-args.ts b/src/shared/agent-session-option-agent-args.ts index d357ea57b73..eed4f1ec3ed 100644 --- a/src/shared/agent-session-option-agent-args.ts +++ b/src/shared/agent-session-option-agent-args.ts @@ -1,35 +1,24 @@ +import { findOptionOccurrence } from './command-option-occurrence' + export function agentArgOptionTokens(tokens: readonly string[]): readonly string[] { const terminator = tokens.indexOf('--') return terminator === -1 ? tokens : tokens.slice(0, terminator) } +/** Removes each occurrence before `--`, or only those whose value `matchesValue` accepts. */ export function removeAgentArgOption( tokens: readonly string[], - aliases: readonly string[] + aliases: readonly string[], + matchesValue: (value: string | undefined) => boolean = () => true ): string[] { - const result: string[] = [] - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index] - if (token === '--') { - result.push(...tokens.slice(index)) - break - } - const exact = aliases.includes(token) - const matched = aliases.some( - (alias) => - token.startsWith(`${alias}=`) || - (alias.startsWith('-') && - !alias.startsWith('--') && - token.startsWith(alias) && - token.length > alias.length) - ) - if (!exact && !matched) { - result.push(token) - continue - } - if (exact && tokens[index + 1] && !tokens[index + 1].startsWith('-')) { - index += 1 - } + const kept: string[] = [] + let rest = tokens + let found = findOptionOccurrence(rest, aliases, true) + while (found) { + const end = found.index + found.consumed + kept.push(...rest.slice(0, matchesValue(found.value) ? found.index : end)) + rest = rest.slice(end) + found = findOptionOccurrence(rest, aliases, true) } - return result + return [...kept, ...rest] } diff --git a/src/shared/agent-session-option-catalog-antigravity.ts b/src/shared/agent-session-option-catalog-antigravity.ts index e6427a1070b..a2078c963d8 100644 --- a/src/shared/agent-session-option-catalog-antigravity.ts +++ b/src/shared/agent-session-option-catalog-antigravity.ts @@ -1,4 +1,3 @@ -import { hasFlag } from './agent-cli-flag-detection' import { removeAgentArgOption } from './agent-session-option-agent-args' import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' @@ -17,7 +16,6 @@ const ANTIGRAVITY_EFFORT: CatalogOption = { }, apply: { launchArgs: (value) => ['--effort', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']), midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } @@ -29,7 +27,6 @@ export const ANTIGRAVITY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']), midSession: { kind: 'agent-picker', command: '/model' } }, diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index b7e9d0e6bed..9fb27d3a97f 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -3,55 +3,24 @@ import type { CatalogModel, CatalogOption } from './agent-session-option-catalog-types' -import { agentArgOptionTokens, removeAgentArgOption } from './agent-session-option-agent-args' +import { removeAgentArgOption } from './agent-session-option-agent-args' import { CLAUDE_MODEL_LIST_ARGS, CLAUDE_MODEL_LIST_STDIN, parseClaudeModelList } from './claude-model-list-probe' -import { hasFlag } from './agent-cli-flag-detection' -function hasCodexEffortOverride(tokens: readonly string[]): boolean { - if (hasFlag(tokens, ['--reasoning-effort'])) { - return true - } - const optionTokens = agentArgOptionTokens(tokens) - return optionTokens.some((token, index) => { - const previous = optionTokens[index - 1] - return ( - (token.startsWith('model_reasoning_effort=') && - (previous === '-c' || previous === '--config')) || - token.startsWith('-cmodel_reasoning_effort=') || - token.startsWith('-c=model_reasoning_effort=') || - token.startsWith('--config=model_reasoning_effort=') - ) - }) -} - -function removeCodexEffortOverride(tokens: readonly string[]): string[] { - const withoutFlag = removeAgentArgOption(tokens, ['--reasoning-effort']) - const result: string[] = [] - for (let index = 0; index < withoutFlag.length; index += 1) { - const token = withoutFlag[index] - if (token === '--') { - result.push(...withoutFlag.slice(index)) - break - } - const next = withoutFlag[index + 1] - if ((token === '-c' || token === '--config') && next?.startsWith('model_reasoning_effort=')) { - index += 1 - continue - } - if ( - token.startsWith('-cmodel_reasoning_effort=') || - token.startsWith('-c=model_reasoning_effort=') || - token.startsWith('--config=model_reasoning_effort=') - ) { - continue - } - result.push(token) - } - return result +// Why: Codex also reads any setting from a `-c key=value` config override. +function removeCodexAgentArgs( + tokens: readonly string[], + flags: readonly string[], + configKey: string +): string[] { + return removeAgentArgOption( + removeAgentArgOption(tokens, flags), + ['-c', '--config'], + (value) => value?.startsWith(`${configKey}=`) === true + ) } const STANDARD_EFFORT_CHOICES = [ @@ -84,7 +53,6 @@ function claudeEffortWithChoices(choices: typeof EXTENDED_EFFORT_CHOICES): Catal }, apply: { launchArgs: (value) => ['--effort', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']), midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } @@ -162,7 +130,6 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ], modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']), midSession: { kind: 'command', @@ -204,8 +171,8 @@ function codexEffort(ceiling: 'xhigh' | 'max' | 'ultra'): CatalogOption { }, apply: { launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], - agentArgsOverride: hasCodexEffortOverride, - removeAgentArgs: removeCodexEffortOverride, + removeAgentArgs: (tokens) => + removeCodexAgentArgs(tokens, ['--reasoning-effort'], 'model_reasoning_effort'), midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } } } @@ -228,8 +195,7 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ], modelApply: { launchArgs: (value) => ['-m', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), - removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), + removeAgentArgs: (tokens) => removeCodexAgentArgs(tokens, ['-m', '--model'], 'model'), // Codex classifies multi-character writes as pasted prose; type the bare // command and let its own picker apply the account-supported model. midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } diff --git a/src/shared/agent-session-option-catalog-codebuddy.ts b/src/shared/agent-session-option-catalog-codebuddy.ts index d0a8afa04c5..9c149919472 100644 --- a/src/shared/agent-session-option-catalog-codebuddy.ts +++ b/src/shared/agent-session-option-catalog-codebuddy.ts @@ -1,4 +1,3 @@ -import { hasFlag } from './agent-cli-flag-detection' import { removeAgentArgOption } from './agent-session-option-agent-args' import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' @@ -20,7 +19,6 @@ const CODEBUDDY_EFFORT: CatalogOption = { }, apply: { launchArgs: (value) => ['--effort', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']) } } @@ -37,7 +35,6 @@ export const CODEBUDDY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ].map((model) => ({ ...model, options: [CODEBUDDY_EFFORT] })), modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']) }, unknownModelOptions: [CODEBUDDY_EFFORT] diff --git a/src/shared/agent-session-option-catalog-gemini-cursor.ts b/src/shared/agent-session-option-catalog-gemini-cursor.ts index d6dfe08627e..510394d2bd5 100644 --- a/src/shared/agent-session-option-catalog-gemini-cursor.ts +++ b/src/shared/agent-session-option-catalog-gemini-cursor.ts @@ -1,4 +1,3 @@ -import { hasFlag } from './agent-cli-flag-detection' import type { AgentSessionOptionCatalog, CatalogModel, @@ -6,7 +5,8 @@ import type { } from './agent-session-option-catalog-types' import { removeAgentArgOption } from './agent-session-option-agent-args' -const hasModelFlag = (tokens: readonly string[]): boolean => hasFlag(tokens, ['-m', '--model']) +const removeModelFlag = (tokens: readonly string[]): string[] => + removeAgentArgOption(tokens, ['-m', '--model']) export const GEMINI_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { models: [ @@ -17,7 +17,7 @@ export const GEMINI_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ], modelApply: { launchArgs: (value) => ['-m', String(value)], - agentArgsOverride: hasModelFlag, + removeAgentArgs: removeModelFlag, midSession: { kind: 'agent-picker', command: '/model' } } } @@ -86,8 +86,7 @@ export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ], modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: hasModelFlag, - removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), + removeAgentArgs: removeModelFlag, midSession: { kind: 'command', build: (value) => `/model ${String(value)}` } }, composeModelValue: (modelId, values) => { diff --git a/src/shared/agent-session-option-catalog-grok.test.ts b/src/shared/agent-session-option-catalog-grok.test.ts index 0295cb46ad7..7a54621c7c6 100644 --- a/src/shared/agent-session-option-catalog-grok.test.ts +++ b/src/shared/agent-session-option-catalog-grok.test.ts @@ -179,32 +179,37 @@ describe('grok launch args', () => { }) }) -describe('grok agentArgsOverride', () => { - const modelOverride = GROK_SESSION_OPTION_CATALOG.modelApply.agentArgsOverride! - const effortOverride = grokEffortOption().apply.agentArgsOverride! +describe('grok agent-arg overrides', () => { + const removeModel = GROK_SESSION_OPTION_CATALOG.modelApply.removeAgentArgs! + const removeEffort = grokEffortOption().apply.removeAgentArgs! - it('detects a user-supplied model flag in every spelling', () => { + it('strips a user-supplied model flag in every spelling', () => { for (const tokens of [ ['-m', 'grok-build'], ['-mgrok-build'], ['--model', 'grok-build'], ['--model=grok-build'] ]) { - expect(modelOverride(tokens)).toBe(true) + expect(removeModel(tokens)).toEqual([]) } }) it('does not fire on a different flag or a positional that contains -m', () => { - expect(modelOverride(['--model-context', '8000'])).toBe(false) - expect(modelOverride(['summarize-my-diff'])).toBe(false) - expect(modelOverride(['--reasoning-effort', 'low'])).toBe(false) - expect(modelOverride([])).toBe(false) + for (const tokens of [ + ['--model-context', '8000'], + ['summarize-my-diff'], + ['--reasoning-effort', 'low'], + [] + ]) { + expect(removeModel(tokens)).toEqual(tokens) + } }) - it('detects both effort spellings', () => { - expect(effortOverride(['--effort', 'low'])).toBe(true) - expect(effortOverride(['--reasoning-effort=low'])).toBe(true) - expect(effortOverride(['--effortless'])).toBe(false) + it('strips both effort spellings', () => { + expect(removeEffort(['--effort', 'low', '--reasoning-effort=high', '--keep'])).toEqual([ + '--keep' + ]) + expect(removeEffort(['--effortless'])).toEqual(['--effortless']) }) it('drops only the overridden key from the launch record', () => { diff --git a/src/shared/agent-session-option-catalog-grok.ts b/src/shared/agent-session-option-catalog-grok.ts index 4f14fdbeade..3577ae036a0 100644 --- a/src/shared/agent-session-option-catalog-grok.ts +++ b/src/shared/agent-session-option-catalog-grok.ts @@ -1,9 +1,9 @@ -import { hasFlag } from './agent-cli-flag-detection' import type { AgentSessionOptionCatalog, CatalogModel, CatalogOption } from './agent-session-option-catalog-types' +import { removeAgentArgOption } from './agent-session-option-agent-args' import { parseGrokModelList } from './grok-model-list-probe' // The offered slice of grok's canonical ladder, low to high. Its `none` tier is @@ -35,7 +35,8 @@ function grokEffort(ceiling: 'high' | 'xhigh'): CatalogOption { }, apply: { launchArgs: (value) => ['--reasoning-effort', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort', '--reasoning-effort']), + // Why: grok reads both spellings. + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort', '--reasoning-effort']), midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } } @@ -67,7 +68,7 @@ export const GROK_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { ], modelApply: { launchArgs: (value) => ['-m', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), // Why: `agent-picker` would replace the whole model list with "Choose in // agent picker…" and never persist a model, so `-m` would never be emitted. midSession: { kind: 'command', build: (value) => `/model ${String(value)}` } diff --git a/src/shared/agent-session-option-catalog-muse.ts b/src/shared/agent-session-option-catalog-muse.ts index 6b5df60fe4d..91163ca7267 100644 --- a/src/shared/agent-session-option-catalog-muse.ts +++ b/src/shared/agent-session-option-catalog-muse.ts @@ -1,4 +1,3 @@ -import { hasFlag } from './agent-cli-flag-detection' import { removeAgentArgOption } from './agent-session-option-agent-args' import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' @@ -22,7 +21,6 @@ const MUSE_EFFORT: CatalogOption = { }, apply: { launchArgs: (value) => ['--reasoning-effort', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--reasoning-effort']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--reasoning-effort']) } } @@ -34,7 +32,6 @@ export const MUSE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']) }, unknownModelOptions: [MUSE_EFFORT] diff --git a/src/shared/agent-session-option-catalog-omp.test.ts b/src/shared/agent-session-option-catalog-omp.test.ts index fdac98154c6..b5bd44e32f3 100644 --- a/src/shared/agent-session-option-catalog-omp.test.ts +++ b/src/shared/agent-session-option-catalog-omp.test.ts @@ -116,13 +116,16 @@ describe('omp session option catalog', () => { }) it('yields to a user --model in the launch args, in either spelling', () => { - const override = OMP_SESSION_OPTION_CATALOG.modelApply.agentArgsOverride! - expect(override(['--model', 'opus'])).toBe(true) - expect(override(['--model=openai/gpt-5.5'])).toBe(true) - expect(override(['--no-extensions'])).toBe(false) + const remove = OMP_SESSION_OPTION_CATALOG.modelApply.removeAgentArgs! + expect(remove(['--model', 'opus', '--model=openai/gpt-5.5', '--no-extensions'])).toEqual([ + '--no-extensions' + ]) // `--models` scopes Ctrl+P cycling; it does not pick a model. omp has no `-m`. - expect(override(['--models=anthropic/*'])).toBe(false) - expect(override(['-m', 'opus'])).toBe(false) + expect(remove(['--models=anthropic/*', '-m', 'opus'])).toEqual([ + '--models=anthropic/*', + '-m', + 'opus' + ]) }) it('switches mid-session with /orca-model , which omp resolves exactly', () => { diff --git a/src/shared/agent-session-option-catalog-omp.ts b/src/shared/agent-session-option-catalog-omp.ts index 33b870b3d7d..312b3f6306a 100644 --- a/src/shared/agent-session-option-catalog-omp.ts +++ b/src/shared/agent-session-option-catalog-omp.ts @@ -1,4 +1,4 @@ -import { hasFlag } from './agent-cli-flag-detection' +import { removeAgentArgOption } from './agent-session-option-agent-args' import type { AgentSessionOptionCatalog, CatalogModel } from './agent-session-option-catalog-types' import { parseOmpModelList } from './omp-model-list-probe' @@ -18,7 +18,7 @@ export const OMP_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { models: [], modelApply: { launchArgs: (value) => ['--model', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']), // OMP only opens its TUI picker for /model; our extension applies the exact selector. midSession: { kind: 'command', build: (value) => `/orca-model ${String(value)}` } }, diff --git a/src/shared/agent-session-option-catalog-removers.test.ts b/src/shared/agent-session-option-catalog-removers.test.ts new file mode 100644 index 00000000000..64262213d4e --- /dev/null +++ b/src/shared/agent-session-option-catalog-removers.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { removeAgentArgOption } from './agent-session-option-agent-args' +import { + removeOverriddenAgentSessionArgs, + resolveAgentSessionOptionLaunch +} from './agent-session-option-launch' + +const removeCodexModel = getAgentSessionOptionCatalog('codex')!.modelApply.removeAgentArgs! + +describe('catalog removers', () => { + it.each([ + [['-c', 'model=o3']], + [['--config', 'model=o3']], + [['-c=model=o3']], + [['-cmodel=o3']], + [['--config=model=o3']] + ])('strips Codex model config %j and yields the picked model to it', (tokens) => { + expect(removeCodexModel([...tokens, '--search'])).toEqual(['--search']) + expect( + resolveAgentSessionOptionLaunch('codex', { model: 'gpt-5.5' }, tokens).appliedValues + ).toEqual({}) + }) + + it('keeps other Codex config, including effort, when stripping the model', () => { + const tokens = [ + '-c', + 'model_reasoning_effort=high', + '-cmodel_reasoning_effort=low', + '-csandbox=x' + ] + expect(removeCodexModel(tokens)).toEqual(tokens) + }) + + it('leaves everything after a terminator', () => { + const tokens = ['--', '-c', 'model=o3'] + expect(removeCodexModel(tokens)).toEqual(tokens) + }) + + it('lets a chat pick replace a configured gemini model on server launches', () => { + expect( + removeOverriddenAgentSessionArgs('gemini', { model: 'gemini-2.5-pro' }, [ + '-m', + 'a', + '--model=b', + '-mc', + '--yolo' + ]) + ).toEqual(['--yolo']) + }) +}) + +describe('removeAgentArgOption', () => { + it('consumes an empty value', () => { + expect(removeAgentArgOption(['--model', '', '--yolo'], ['--model'])).toEqual(['--yolo']) + }) + + it('keeps a following flag', () => { + expect(removeAgentArgOption(['--model', '--yolo'], ['--model'])).toEqual(['--yolo']) + }) +}) diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index 74e488e5549..fe15d5080e0 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -21,10 +21,8 @@ export type CatalogMidSessionApply = export type CatalogOptionApply = { launchArgs?: (value: SessionOptionValue) => string[] - /** Why: later free-form args win, so the launch record must discard any - * picker value that those args may have replaced. */ - agentArgsOverride?: (tokens: readonly string[]) => boolean - /** Removes conflicting defaults before a more specific launch choice is inserted. */ + /** Strips the free-form args that set this option. Why: later free-form args win, so + * anything this strips also tells the launch record to discard the picker value. */ removeAgentArgs?: (tokens: readonly string[]) => string[] composedIntoModel?: true midSession?: CatalogMidSessionApply @@ -75,7 +73,7 @@ export type AgentSessionOptionCatalog = { * * Known gap: "no model flag" is unverified. A user `-m` in `agentArgs` launches that * model while the picker, which never reads launch args, still names the CLI default. - * A real fix means threading `modelApply.agentArgsOverride` through to the surface. */ + * A real fix means threading `modelApply.removeAgentArgs` through to the surface. */ defaultModelIsCliDefault?: true listModels?: { command: string diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index 53df967a838..e9c5b90181d 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -1,5 +1,6 @@ import type { AgentType } from './agent-status-types' import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import type { CatalogOptionApply } from './agent-session-option-catalog-types' import type { SessionOptionValue } from './native-chat-session-options' export type ResolvedSessionOptionLaunch = { @@ -7,6 +8,11 @@ export type ResolvedSessionOptionLaunch = { appliedValues: Record } +function isOverriddenByAgentArgs(apply: CatalogOptionApply, tokens: readonly string[]): boolean { + const kept = apply.removeAgentArgs?.(tokens) + return kept !== undefined && kept.length < tokens.length +} + export function removeOverriddenAgentSessionArgs( agent: AgentType, values: Record | null | undefined, @@ -63,7 +69,7 @@ export function resolveAgentSessionOptionLaunch( const composedModelId = catalog.composeModelValue ? catalog.composeModelValue(modelId, modelValues) : modelId - const modelOverridden = catalog.modelApply.agentArgsOverride?.(trailingAgentArgs) === true + const modelOverridden = isOverriddenByAgentArgs(catalog.modelApply, trailingAgentArgs) if (catalog.modelApply.launchArgs) { args.push(...catalog.modelApply.launchArgs(composedModelId)) @@ -86,7 +92,7 @@ export function resolveAgentSessionOptionLaunch( continue } args.push(...option.apply.launchArgs(value)) - if (!modelOverridden && !option.apply.agentArgsOverride?.(trailingAgentArgs)) { + if (!modelOverridden && !isOverriddenByAgentArgs(option.apply, trailingAgentArgs)) { appliedValues[option.id] = value } } diff --git a/src/shared/agent-status-store-copying-oracle.test-fixture.ts b/src/shared/agent-status-store-copying-oracle.test-fixture.ts index 06446079c56..a71f009b40b 100644 --- a/src/shared/agent-status-store-copying-oracle.test-fixture.ts +++ b/src/shared/agent-status-store-copying-oracle.test-fixture.ts @@ -10,12 +10,15 @@ import { import { deserializeAgentChildWorkBindingKey } from './agent-status-child-work-binding' import { agentChildWorkBelongsTo, type AgentChildWorkRecord } from './agent-status-child-work' import { parseAgentStatusStoreMutation } from './agent-status-store-codec' -import type { AgentStatusStoreSnapshot } from './agent-status-store-contract' +import { + AGENT_STATUS_STORE_SNAPSHOT_VERSION, + type AgentStatusStoreSnapshot +} from './agent-status-store-contract' import { applyAgentStatusStoreMutationSteps } from './agent-status-store-mutation' import { cloneAgentStatusStoreState, createEmptyAgentStatusStoreState, - snapshotFromAgentStatusStoreState, + deepFreezeAgentStatusStoreValue, validateAgentStatusStoreState, type AgentStatusStoreState } from './agent-status-store-state' @@ -59,7 +62,7 @@ function applyByCopy(current: AgentStatusStoreState, value: unknown): AgentStatu export type CopyingAgentStatusStoreOracle = { applyMutation(mutation: unknown): boolean getSnapshot(): AgentStatusStoreSnapshot - /** The oracle's own state, for checking every invariant after each step. */ + /** The oracle's own maps, for fixture coverage and diagnostics. */ state(): AgentStatusStoreState getChildren(subject: AgentStatusSubject): AgentChildWorkRecord[] getAliasesForChild(childWorkId: string): AgentChildWorkAliasRecord[] @@ -76,7 +79,18 @@ export function createCopyingAgentStatusStoreOracle(epoch: string): CopyingAgent } return next !== null }, - getSnapshot: () => snapshotFromAgentStatusStoreState(current), + // Project the validated maps independently of the production snapshot parser. + getSnapshot: () => + deepFreezeAgentStatusStoreValue({ + version: AGENT_STATUS_STORE_SNAPSHOT_VERSION, + epoch: current.epoch, + revision: current.revision, + parents: [...current.parents.values()], + children: [...current.children.values()], + aliases: [...current.aliases.values()], + facts: [...current.facts.values()], + tombstones: [...current.tombstones.values()] + }), state: () => current, getChildren: (subject) => [...current.children.values()].filter((child) => agentChildWorkBelongsTo(child, subject)), diff --git a/src/shared/agent-status-store-in-place.test.ts b/src/shared/agent-status-store-in-place.test.ts index efc999aef56..f4676492ebb 100644 --- a/src/shared/agent-status-store-in-place.test.ts +++ b/src/shared/agent-status-store-in-place.test.ts @@ -8,16 +8,17 @@ import type { AgentChildWorkInput } from './agent-status-child-work' import { createAgentStatusStore } from './agent-status-store' import { parseAgentStatusStoreMutation } from './agent-status-store-codec' import { commitAgentStatusStoreMutation } from './agent-status-store-commit' -import type { AgentStatusStoreMutation } from './agent-status-store-contract' +import { + AGENT_STATUS_STORE_LIMITS, + AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS, + type AgentStatusStoreMutation +} from './agent-status-store-contract' import { createCopyingAgentStatusStoreOracle } from './agent-status-store-copying-oracle.test-fixture' import { indexAgentStatusStoreState, type AgentStatusStoreIndexes } from './agent-status-store-indexes' -import { - createEmptyAgentStatusStoreState, - validateAgentStatusStoreState -} from './agent-status-store-state' +import { createEmptyAgentStatusStoreState } from './agent-status-store-state' import { makeStructuredAgentStatusSubject, serializeAgentStatusSubject, @@ -163,7 +164,6 @@ describe('AgentStatusStore applied in place', () => { accepted += 1 expect(replica.applyTransportEnvelope(envelope)).toBe(true) } - expect(validateAgentStatusStoreState(oracle.state())).toBe(true) expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) for (const parent of parents) { expect(store.getChildren(parent)).toEqual(oracle.getChildren(parent)) @@ -206,18 +206,51 @@ describe('AgentStatusStore applied in place', () => { 60_000 ) - it('compacts tombstones exactly as the copying store does past the retention window', () => { + it('retires tombstones exactly at the retention revision boundary', () => { const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) const oracle = createCopyingAgentStatusStoreOracle('epoch-a') const parent = parents[0]! - for (let step = 0; step < 5_000; step += 1) { - const mutation: AgentStatusStoreMutation = - step % 2 === 0 - ? { parent: { subject: parent }, facts: [{ subject: parent, key: `k${step}`, value: 1 }] } - : { removeFacts: [{ subject: parent, key: `k${step - 1}` }] } + for (const childWorkId of ['oldest', 'newer']) { + const mutation = { removeChildren: [childWorkId] } expect(store.applyMutation(mutation) !== null).toBe(oracle.applyMutation(mutation)) } - expect(oracle.getSnapshot().tombstones.length).toBeGreaterThan(1_000) + const advance = { parent: { subject: parent } } + for ( + let revision = 3; + revision <= AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS; + revision += 1 + ) { + expect(store.applyMutation(advance) !== null).toBe(oracle.applyMutation(advance)) + } + expect(store.getSnapshot().revision).toBe(AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS) + expect(store.getSnapshot().tombstones).toEqual([ + { entity: 'child', key: 'oldest', revision: 1 }, + { entity: 'child', key: 'newer', revision: 2 } + ]) + expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) + + expect(store.applyMutation(advance) !== null).toBe(oracle.applyMutation(advance)) + expect(store.getSnapshot().tombstones).toEqual([{ entity: 'child', key: 'newer', revision: 2 }]) + expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) + + expect(store.applyMutation(advance) !== null).toBe(oracle.applyMutation(advance)) + expect(store.getSnapshot().tombstones).toEqual([]) + expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) + }, 60_000) + + it('keeps the newest tombstones in order when their count overflows', () => { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + const oracle = createCopyingAgentStatusStoreOracle('epoch-a') + const childWorkIds = Array.from( + { length: AGENT_STATUS_STORE_LIMITS.tombstones }, + (_, index) => `removed-${index}` + ) + for (const mutation of [{ removeChildren: ['oldest'] }, { removeChildren: childWorkIds }]) { + expect(store.applyMutation(mutation) !== null).toBe(oracle.applyMutation(mutation)) + } + expect(store.getSnapshot().tombstones).toEqual( + childWorkIds.map((key) => ({ entity: 'child', key, revision: 2 })) + ) expect(JSON.stringify(store.getSnapshot())).toBe(JSON.stringify(oracle.getSnapshot())) }, 60_000) }) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 18ec79ac74c..3d5ef9458e6 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -9,7 +9,7 @@ import { isAgentTurnOutcome } from './agent-turn-outcome' import type { OrchestrationFleetAttention } from './orchestration-fleet-attention' import type { AgentStatusRowFacets } from './agent-status-observation' import type { AgentChildWorkView } from './agent-status-child-work-view' -import type { TuiAgent } from './tui-agent' +import type { TerminalAgent } from './terminal-agent' import { AGENT_MODEL_MAX_LENGTH, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, @@ -53,9 +53,9 @@ export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] export type AgentWorkingMode = 'monitoring' // Why: agent types aren't a fixed set (custom agents exist); any non-empty string is -// accepted — the well-known names are the launchable TuiAgent ids plus the 'unknown' +// accepted — the well-known names are the recognized TerminalAgent ids plus the 'unknown' // sentinel (no agent identified yet), a convenience union for pattern-matching. -export type WellKnownAgentType = TuiAgent | 'unknown' +export type WellKnownAgentType = TerminalAgent | 'unknown' export type AgentType = WellKnownAgentType | (string & {}) export type AgentStatusOrchestrationContext = { diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index a3bc238a7e1..4e3d88153bc 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -1,3 +1,23 @@ +import { + GEMINI_WORKING, + GEMINI_SILENT_WORKING, + GEMINI_IDLE, + GEMINI_PERMISSION, + containsBrailleSpinner +} from './agent-title-glyphs' +export { + CLAUDE_IDLE, + GEMINI_WORKING, + GEMINI_SILENT_WORKING, + GEMINI_IDLE, + GEMINI_PERMISSION, + BRAILLE_SPINNER_RE, + QUARTER_CIRCLE_SPINNER_RE, + containsBrailleSpinner, + containsQuarterCircleSpinner, + containsAgentSpinnerGlyph +} from './agent-title-glyphs' +import { isDeepSeekBuildTerminalTitle } from './dsb-terminal-title' import { isDshTerminalTitle } from './dsh-terminal-title' export { DSH_WHALE, isDshTerminalTitle } from './dsh-terminal-title' import { isQoderTerminalTitle } from './qoder-terminal-title' @@ -17,18 +37,12 @@ export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasA export type AgentStatus = 'working' | 'permission' | 'idle' -export const CLAUDE_IDLE = '\u2733' // ✳ const CLAUDE_COMMAND_RE = String.raw`(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?` export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp( String.raw`^\s*(?:"${CLAUDE_COMMAND_RE}"|'${CLAUDE_COMMAND_RE}'|${CLAUDE_COMMAND_RE})\s+agents\s*$`, 'i' ) -export const GEMINI_WORKING = '\u2726' // ✦ -export const GEMINI_SILENT_WORKING = '\u23f2' // ⏲ -export const GEMINI_IDLE = '\u25c7' // ◇ -export const GEMINI_PERMISSION = '\u270b' // ✋ - const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const @@ -50,29 +64,25 @@ export const STRONG_WORKING_KEYWORDS_RE_GLOBAL = new RegExp(STRONG_WORKING_KEYWO export const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent' -// eslint-disable-next-line no-control-regex -- intentional unicode range -export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g - -// Why: Claude Code 2.1.228 swapped its busy title spinner from braille to -// quarter circles (#13889), which read as "no agent" and looked like an exit. -// Reserve the whole quarter-circle block so a later frame addition cannot regress this. -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 ( +export function hasGeminiStatusGlyph(title: string): boolean { + return ( title.includes(GEMINI_PERMISSION) || title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING) || title.includes(GEMINI_IDLE) - ) { + ) +} + +function computeIsGeminiTerminalTitle(title: string): boolean { + // Why: native Qoder and DSH markers own titles before the shared Gemini glyphs. + if (isQoderTerminalTitle(title)) { + return false + } + if (isDshTerminalTitle(title) || isDeepSeekBuildTerminalTitle(title)) { + return false + } + // Why: Gemini OSC glyphs are stronger evidence than any cwd/session text. + if (hasGeminiStatusGlyph(title)) { return true } // Why: Pi/OMP titles include cwd/session text; substring matching made @@ -104,35 +114,6 @@ export function isPiAgentTitle(title: string): boolean { return isLegacyPiCompatibleTitle(title) } -export function containsBrailleSpinner(title: string): boolean { - for (const char of title) { - const codePoint = char.codePointAt(0) - if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) { - return true - } - } - return false -} - -export function containsQuarterCircleSpinner(title: string): boolean { - for (const char of title) { - const codePoint = char.codePointAt(0) - if (codePoint !== undefined && codePoint >= 0x25d0 && codePoint <= 0x25d3) { - return true - } - } - return false -} - -/** - * Any spinner frame glyph an agent animates its OSC title with. Use this for - * generic "something is running" checks; agent-specific frame shapes (Grok, - * Pi, synthetic Cursor) stay pinned to their own glyph set. - */ -export function containsAgentSpinnerGlyph(title: string): boolean { - return containsBrailleSpinner(title) || containsQuarterCircleSpinner(title) -} - export function containsLegacyAgentName(title: string): boolean { return titleHasAnyLegacyAgentName(title) } diff --git a/src/shared/agent-title-evidence.test.ts b/src/shared/agent-title-evidence.test.ts index 66840fe540d..a24785a23f3 100644 --- a/src/shared/agent-title-evidence.test.ts +++ b/src/shared/agent-title-evidence.test.ts @@ -6,11 +6,36 @@ import { GEMINI_WORKING } from './agent-title-core' import { collectAgentTitleEvidence } from './agent-title-evidence' +import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter' +import { resolvePublishedPaneAgentIdentity } from './published-pane-agent-identity' const agentFor = (title: string) => collectAgentTitleEvidence(title).agent const reasonFor = (title: string) => collectAgentTitleEvidence(title).reason describe('collectAgentTitleEvidence', () => { + it.each([ + ['✦ Review Codex - DeepSeek Build', 'gemini'], + ['⏲ Review Codex - DeepSeek Build', 'gemini'], + ['◇ Review Codex - DeepSeek Build', 'gemini'], + ['✋ Review Codex - DeepSeek Build', 'gemini'], + ['✳ Review Codex - DeepSeek Build', 'claude'], + ['. Review Codex - DeepSeek Build', 'claude'], + ['* Review Codex - DeepSeek Build', 'claude'], + ['Wrapper | ✦ Review Codex - DeepSeek Build', 'gemini'], + ['Wrapper | ✳ Review Codex - DeepSeek Build', 'claude'], + ['OC | Review Codex - DeepSeek Build', 'opencode'], + ['Wrapper | OC | Review Codex - DeepSeek Build', 'opencode'], + ['⠋ - Review Codex - DeepSeek Build', 'dsb'], + ['DeepSeek Build', 'dsb'] + ] as const)( + 'preserves the native owner of %j through canonical and published identity', + (title, agent) => { + expect(collectAgentTitleEvidence(title).agent).toBe(agent) + expect(resolveCanonicalPaneAgentIdentity({ title }).agent).toBe(agent) + expect(resolvePublishedPaneAgentIdentity({ title })).toBe(agent) + } + ) + describe('an anchored name outranks a name in task text', () => { // Minimized from real recorded titles that resolve to the wrong agent on the ordered chain: // the pane owner is named by Orca's `- ` suffix, the competitor only by task text. diff --git a/src/shared/agent-title-evidence.ts b/src/shared/agent-title-evidence.ts index 01b8c82f014..5328baa0f05 100644 --- a/src/shared/agent-title-evidence.ts +++ b/src/shared/agent-title-evidence.ts @@ -14,13 +14,14 @@ import { titleHasAgentName } from './agent-title-core' import { isOpenCodeNativeTitle } from './opencode-terminal-title' +import { isDeepSeekBuildTerminalTitle } from './dsb-terminal-title' import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration' import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' import { SYNTHETIC_AGENT_TITLE_AGENTS, SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title' -import type { TuiAgent } from './tui-agent' +import type { TerminalAgent } from './terminal-agent' import { TUI_AGENT_DISPLAY_NAMES } from './tui-agent-display-names' /** @@ -49,16 +50,16 @@ export type AgentTitleEvidenceReason = | 'no-evidence' export type AgentTitleEvidence = { - readonly vendorMarkers: readonly TuiAgent[] - readonly anchoredNames: readonly TuiAgent[] - readonly freeTextNames: readonly TuiAgent[] + readonly vendorMarkers: readonly TerminalAgent[] + readonly anchoredNames: readonly TerminalAgent[] + readonly freeTextNames: readonly TerminalAgent[] /** Null whenever the title cannot answer on its own. Callers fall back to stronger signals. */ - readonly agent: TuiAgent | null + readonly agent: TerminalAgent | null readonly reason: AgentTitleEvidenceReason } /** Names matched as whole tokens, paired with the agent each identifies. */ -const NAME_TOKENS: readonly (readonly [string, TuiAgent])[] = [ +const NAME_TOKENS: readonly (readonly [string, TerminalAgent])[] = [ ['claude', 'claude'], ['openclaude', 'openclaude'], ['codex', 'codex'], @@ -75,7 +76,7 @@ const NAME_TOKENS: readonly (readonly [string, TuiAgent])[] = [ ] /** Agents whose name is matched by a dedicated pattern rather than a plain token. */ -const PATTERN_NAMES: readonly (readonly [RegExp, TuiAgent])[] = [ +const PATTERN_NAMES: readonly (readonly [RegExp, TerminalAgent])[] = [ [AGY_AGENT_NAME_RE, 'antigravity'], [DROID_AGENT_NAME_RE, 'droid'], [HERMES_AGENT_NAME_RE, 'hermes'] @@ -88,7 +89,7 @@ const EMITTED_DISPLAY_LABEL_AGENTS = [ 'prime-agent', 'command-code', 'copilot' -] as const satisfies readonly TuiAgent[] +] as const satisfies readonly TerminalAgent[] const DISPLAY_LABELS = [ ...EMITTED_DISPLAY_LABEL_AGENTS.map( @@ -97,7 +98,7 @@ const DISPLAY_LABELS = [ ['claude code', 'claude'], ['gemini cli', 'gemini'], ['agent teams', 'claude-agent-teams'] -] satisfies readonly (readonly [string, TuiAgent])[] +] satisfies readonly (readonly [string, TerminalAgent])[] const GEMINI_GLYPHS = [GEMINI_WORKING, GEMINI_SILENT_WORKING, GEMINI_IDLE, GEMINI_PERMISSION] const ANTIGRAVITY_MODEL_TITLE_RE = /^(?:agy|antigravity)(?:\s*[·—:-]\s*|\s+)gemini\s+\d/i @@ -111,7 +112,7 @@ const OWNER_SUFFIX_RE = /\s-\s+([A-Za-z][\w-]*)\s*$/ const WINDOWS_LAUNCHER_SUFFIX_RE = /\.(?:exe|cmd|bat|ps1)$/i const WRAPPER_SEPARATOR = ' | ' const MAX_WRAPPER_EVIDENCE_SEGMENTS = 8 -const RESERVED_OWNER_IDS: ReadonlyMap = new Map([ +const RESERVED_OWNER_IDS: ReadonlyMap = new Map([ ['pi', 'pi'], ['omp', 'omp'], ['claude-agent-teams', 'claude-agent-teams'], @@ -135,8 +136,8 @@ function getEvidenceTitleSegments(title: string): string[] { return segments } -function namesIn(text: string): TuiAgent[] { - const found = new Set() +function namesIn(text: string): TerminalAgent[] { + const found = new Set() for (const [token, agent] of NAME_TOKENS) { if (titleHasAgentName(text, token)) { found.add(agent) @@ -157,7 +158,7 @@ function stripBareNameDecoration(text: string): string { .replace(/[^\p{L}\p{N}]+$/u, '') } -function agentForBareName(text: string): TuiAgent | null { +function agentForBareName(text: string): TerminalAgent | null { const trimmed = text.trim() if (!trimmed || /[\\/]/.test(trimmed)) { return null @@ -177,7 +178,7 @@ function agentForBareName(text: string): TuiAgent | null { return names.length === 1 && /^[\p{L}\p{N}]+$/u.test(bareToken) ? names[0] : null } -function agentForWholeTitle(text: string): TuiAgent | null { +function agentForWholeTitle(text: string): TerminalAgent | null { const trimmed = text.trim() if (!trimmed || /[\\/]/.test(trimmed)) { return null @@ -194,12 +195,12 @@ function agentForWholeTitle(text: string): TuiAgent | null { return agentForBareName(stripped) } -function agentForOwnerSuffix(text: string): TuiAgent | null { +function agentForOwnerSuffix(text: string): TerminalAgent | null { const normalized = text.trim().toLowerCase() return RESERVED_OWNER_IDS.get(normalized) ?? agentForBareName(text) } -function agentForSyntheticTitle(text: string): TuiAgent | null { +function agentForSyntheticTitle(text: string): TerminalAgent | null { const trimmed = text.trim() if (/[\\/]/.test(trimmed)) { return null @@ -228,8 +229,8 @@ function agentForSyntheticTitle(text: string): TuiAgent | null { return null } -function collectVendorMarkers(segments: readonly string[]): TuiAgent[] { - const markers = new Set() +function collectVendorMarkers(segments: readonly string[]): TerminalAgent[] { + const markers = new Set() for (const segment of segments) { // Why prefix-only: a sigil marks the pane's own status line only in the identity position. // The same character inside task text is decoration, not a vendor emission. @@ -253,9 +254,9 @@ function collectVendorMarkers(segments: readonly string[]): TuiAgent[] { function namesConsumedByAnchoredLabels( segments: readonly string[], - anchoredNames: ReadonlySet -): Set { - const consumed = new Set() + anchoredNames: ReadonlySet +): Set { + const consumed = new Set() for (const segment of segments) { const normalized = stripBareNameDecoration(segment).toLowerCase() const label = DISPLAY_LABELS.find(([text]) => text === normalized) @@ -268,8 +269,13 @@ function namesConsumedByAnchoredLabels( return consumed } -function collectAnchoredNames(segments: readonly string[]): TuiAgent[] { - const anchored = new Set() +function collectAnchoredNames( + segments: readonly string[], + vendorMarkers: readonly TerminalAgent[] +): TerminalAgent[] { + const anchored = new Set() + // Why: a native owner's task text can end with DSB's product-name suffix, including inside wrappers. + const allowDsbTitle = vendorMarkers.length === 0 && !segments.some(isOpenCodeNativeTitle) for (const segment of segments) { // Why anchored and not a bare marker: the native envelope owns the whole wrapped pane title. @@ -277,6 +283,9 @@ function collectAnchoredNames(segments: readonly string[]): TuiAgent[] { if (isOpenCodeNativeTitle(segment)) { anchored.add('opencode') } + if (allowDsbTitle && isDeepSeekBuildTerminalTitle(segment)) { + anchored.add('dsb') + } const suffix = OWNER_SUFFIX_RE.exec(segment) if (suffix) { @@ -332,7 +341,7 @@ export function collectAgentTitleEvidence(title: string): AgentTitleEvidence { const segments = getEvidenceTitleSegments(title) const vendorMarkers = collectVendorMarkers(segments) - const anchoredNames = collectAnchoredNames(segments) + const anchoredNames = collectAnchoredNames(segments, vendorMarkers) const anchoredSet = new Set(anchoredNames) const anchoredLabelNames = namesConsumedByAnchoredLabels(segments, anchoredSet) const freeTextNames = namesIn(title).filter( diff --git a/src/shared/agent-title-glyphs.ts b/src/shared/agent-title-glyphs.ts new file mode 100644 index 00000000000..a30f2d9a5be --- /dev/null +++ b/src/shared/agent-title-glyphs.ts @@ -0,0 +1,42 @@ +export const CLAUDE_IDLE = '\u2733' // ✳ +export const GEMINI_WORKING = '\u2726' // ✦ +export const GEMINI_SILENT_WORKING = '\u23f2' // ⏲ +export const GEMINI_IDLE = '\u25c7' // ◇ +export const GEMINI_PERMISSION = '\u270b' // ✋ + +// eslint-disable-next-line no-control-regex -- intentional unicode range +export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g + +// Why: Claude Code 2.1.228 swapped its busy title spinner from braille to +// quarter circles (#13889), which read as "no agent" and looked like an exit. +// Reserve the whole quarter-circle block so a later frame addition cannot regress this. +export const QUARTER_CIRCLE_SPINNER_RE = /[\u25d0-\u25d3]/g + +export function containsBrailleSpinner(title: string): boolean { + for (const char of title) { + const codePoint = char.codePointAt(0) + if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) { + return true + } + } + return false +} + +export function containsQuarterCircleSpinner(title: string): boolean { + for (const char of title) { + const codePoint = char.codePointAt(0) + if (codePoint !== undefined && codePoint >= 0x25d0 && codePoint <= 0x25d3) { + return true + } + } + return false +} + +/** + * Any spinner frame glyph an agent animates its OSC title with. Use this for + * generic "something is running" checks; agent-specific frame shapes (Grok, + * Pi, synthetic Cursor) stay pinned to their own glyph set. + */ +export function containsAgentSpinnerGlyph(title: string): boolean { + return containsBrailleSpinner(title) || containsQuarterCircleSpinner(title) +} diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 578ea3e9bbf..baf78f38a64 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -12,6 +12,7 @@ import { isPiAgentTitle, titleHasAgentName } from './agent-title-core' +import { isDeepSeekBuildTerminalTitle } from './dsb-terminal-title' import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' import { memoizeTitleClassification } from './terminal-title-classification-memo' @@ -34,6 +35,10 @@ function computeIsClaudeAgent(title: string): boolean { if (title.startsWith('. ') || title.startsWith('* ')) { return true } + // Why: a working DeepSeek Build title uses Claude's braille frame. + if (isDeepSeekBuildTerminalTitle(title)) { + return false + } if (containsAgentSpinnerGlyph(title)) { // Why: named non-Claude agents carry braille spinners too. Gate Cursor by its // identity title, not the token, so a Claude title mentioning a cursor stays Claude. @@ -76,6 +81,10 @@ function computeAgentLabel(title: string): string | null { if (isQoderTerminalTitle(title)) { return title.includes('Qoder CLI CN') ? 'Qoder CLI CN' : 'Qoder CLI' } + // Why: the DSB matcher distinguishes native prefixes from glyphs inside task text. + if (isDeepSeekBuildTerminalTitle(title)) { + return 'DeepSeek Build' + } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 5dbd8e4a876..a594ee19dd9 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -1,3 +1,4 @@ +import { getDeepSeekBuildTitleStatus } from './dsb-terminal-title' import { qoderTitleStatus } from './qoder-terminal-title' import { AGY_AGENT_NAME_RE, @@ -130,6 +131,9 @@ export function createAgentStatusTracker( * Normalize high-churn agent titles into stable display labels before storage. */ export function normalizeTerminalTitle(title: string): string { + if (getDeepSeekBuildTitleStatus(title)) { + return title + } const qoderStatus = qoderTitleStatus(title) if (qoderStatus) { const label = title.includes('Qoder CLI CN') ? 'Qoder CLI CN' : 'Qoder CLI' @@ -187,6 +191,10 @@ function canonicalizeBrailleSpinnerFrame(title: string): string { } function computeAgentStatusFromTitle(title: string): AgentStatus | null { + const buildStatus = getDeepSeekBuildTitleStatus(title) + if (buildStatus) { + return buildStatus + } const qoderStatus = qoderTitleStatus(title) if (qoderStatus) { return qoderStatus diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index d6b2dc40242..a8996e7985a 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -32,6 +32,7 @@ const WELL_KNOWN_LABELS: Record = { muse: 'Muse', zcode: 'ZCode', dsh: 'DeepSeek Harness', + dsb: 'DeepSeek Build', jcode: 'Jcode' } diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts index c00b27e6b2c..3ca5184ed1f 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -13,10 +13,7 @@ import { redactStatusForTransport, type SessionSearchTransport } from './ai-vault-search-transport' -import { - compatibleSearchAgents, - needsSearchAgentNegotiation -} from './ai-vault-search-agent-compatibility' +import { compatibleSearchAgents } from './ai-vault-search-agent-compatibility' import { AI_VAULT_AGENTS } from './ai-vault-types' export function unavailableSessionSearchStatus(): AiVaultSearchStatus { @@ -55,12 +52,8 @@ export function createSessionSearchClient( let raw: unknown try { // IPC and its all-hosts merge are this build; each remote leg negotiates its own host. - if ( - transport !== 'ipc' && - parsed.filters?.agents && - needsSearchAgentNegotiation(parsed.filters.agents) - ) { - const status = AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})) + if (transport !== 'ipc' && parsed.filters?.agents?.length) { + const status = await readSessionSearchStatus(call) const agents = compatibleSearchAgents(parsed.filters.agents, status) if (agents.length === 0) { return { kind: 'unavailable', reason: 'unsupported-agent' } @@ -90,18 +83,20 @@ export function createSessionSearchClient( ...(parsed.debug && debug ? { debug } : {}) } }, - searchStatus: async () => { - try { - return redactStatusForTransport( - AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})), - transport - ) - } catch (error) { - if (isUnknownSessionSearchMethod(error)) { - return unavailableSessionSearchStatus() - } - throw error - } + searchStatus: async () => + redactStatusForTransport(await readSessionSearchStatus(call), transport) + } +} + +async function readSessionSearchStatus( + call: (method: string, params: Record) => Promise +): Promise { + try { + return AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})) + } catch (error) { + if (isUnknownSessionSearchMethod(error)) { + return unavailableSessionSearchStatus() } + throw error } } diff --git a/src/shared/ai-vault-search-status-fallback.test.ts b/src/shared/ai-vault-search-status-fallback.test.ts new file mode 100644 index 00000000000..56c9118b736 --- /dev/null +++ b/src/shared/ai-vault-search-status-fallback.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSessionSearchClient, unavailableSessionSearchStatus } from './ai-vault-search-client' +import { AiVaultSearchRequestSchema as LegacyRequestSchema } from './__fixtures__/pre-qoder-search-request' +import { searchResults } from './ai-vault-search-test-fixture' + +const query = { + query: 'needle', + scope: 'conversation' as const, + limit: 7, + cursor: 'next-page', + filters: { agents: ['claude'] as const, scopePaths: ['/execution-host/folder'] } +} + +describe.each(['runtime', 'relay'] as const)('missing search status over %s', (transport) => { + it.each([-32601, 'method_not_found'])( + 'searches legacy filters after status refusal %s', + async (code) => { + const call = vi.fn(async (method: string, params: Record) => { + if (method === 'aiVault.searchStatus') { + throw { code } + } + expect(LegacyRequestSchema.parse(params)).toEqual({ + ...query, + filters: { ...query.filters, agents: ['claude'] } + }) + return searchResults() + }) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + ...query, + filters: { ...query.filters, agents: ['claude'] } + }) + ).toMatchObject({ kind: 'results' }) + expect(call.mock.calls.map(([method]) => method)).toEqual([ + 'aiVault.searchStatus', + 'aiVault.searchSessions' + ]) + } + ) + + it('keeps only the conservative legacy subset when status is absent', async () => { + const call = vi.fn(async (method: string, params: Record) => { + if (method === 'aiVault.searchStatus') { + throw { code: 'method_not_found' } + } + expect(LegacyRequestSchema.parse(params)).toEqual({ + ...query, + filters: { ...query.filters, agents: ['claude'] } + }) + return searchResults() + }) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + ...query, + filters: { ...query.filters, agents: ['jcode', 'qoder', 'codebuddy', 'zcode', 'claude'] } + }) + ).toMatchObject({ kind: 'results' }) + expect(call).toHaveBeenCalledTimes(2) + }) + + it('does not dispatch an unsupported-only filter after status refusal', async () => { + const call = vi.fn(async () => { + throw { code: 'method_not_found' } + }) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + query: 'needle', + filters: { agents: ['jcode'] } + }) + ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + }) + + it.each([{ supportedAgents: [] }, { supportedAgents: ['future-agent'] }])( + 'honors explicit narrow host vocabulary $supportedAgents', + async ({ supportedAgents }) => { + const call = vi.fn(async () => ({ ...unavailableSessionSearchStatus(), supportedAgents })) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + query: 'needle', + filters: { agents: ['claude'] } + }) + ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + } + ) + + it.each([ + { code: 'unauthorized', message: 'Authentication required' }, + { code: 'connection_closed', message: 'Host disconnected' }, + { code: -32000, message: 'Execution host refused request' } + ])('propagates status failure $code without a search', async (error) => { + const call = vi.fn(async () => { + throw error + }) + await expect( + createSessionSearchClient(call, transport).searchSessions({ + query: 'needle', + filters: { agents: ['claude'] } + }) + ).rejects.toEqual(error) + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + }) + + it('propagates a malformed status instead of assuming a legacy host', async () => { + const call = vi.fn(async () => ({ supportedAgents: ['claude'] })) + await expect( + createSessionSearchClient(call, transport).searchSessions({ + query: 'needle', + filters: { agents: ['claude'] } + }) + ).rejects.toThrow() + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + }) + + it('reports no service only when the search method itself is missing', async () => { + const call = vi.fn(async () => { + throw { code: 'method_not_found' } + }) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + query: 'needle', + filters: { agents: ['claude'] } + }) + ).toEqual({ kind: 'unavailable', reason: 'no-service' }) + expect(call.mock.calls.map(() => true)).toHaveLength(2) + }) +}) diff --git a/src/shared/browser-annotation-viewport-bridge.ts b/src/shared/browser-annotation-viewport-bridge.ts index f30a8a5fbef..c9846eb3116 100644 --- a/src/shared/browser-annotation-viewport-bridge.ts +++ b/src/shared/browser-annotation-viewport-bridge.ts @@ -111,6 +111,9 @@ export function buildBrowserAnnotationViewportBridgeScript({ document.removeEventListener('scroll', state.requestUpdate, true); window.removeEventListener('resize', state.requestUpdate, true); } + if (state.onBeforeUnload) { + window.removeEventListener('beforeunload', state.onBeforeUnload); + } removeOverlay(state); }; @@ -234,7 +237,8 @@ export function buildBrowserAnnotationViewportBridgeScript({ markerElements: new Map(), markers: [], shadowRoot: null, - requestUpdate: null + requestUpdate: null, + onBeforeUnload: null }; state.requestUpdate = () => { @@ -246,10 +250,17 @@ export function buildBrowserAnnotationViewportBridgeScript({ }); }; + // Navigation can hold IPC cleanup until the next document finishes loading. + state.onBeforeUnload = () => { + cleanup(state); + delete globalThis[stateKey]; + }; + updateMarkers(state, markers); window.addEventListener('scroll', state.requestUpdate, true); document.addEventListener('scroll', state.requestUpdate, true); window.addEventListener('resize', state.requestUpdate, true); + window.addEventListener('beforeunload', state.onBeforeUnload); globalThis[stateKey] = state; state.requestUpdate(); return true; diff --git a/src/shared/browser-annotation-viewport-lifecycle.test.ts b/src/shared/browser-annotation-viewport-lifecycle.test.ts new file mode 100644 index 00000000000..cc7fbe8a4d7 --- /dev/null +++ b/src/shared/browser-annotation-viewport-lifecycle.test.ts @@ -0,0 +1,74 @@ +import { runInNewContext } from 'node:vm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildBrowserAnnotationViewportBridgeScript } from './browser-annotation-viewport-bridge' + +const guestAborts: (() => Promise)[] = [] + +afterEach(async () => { + await Promise.all(guestAborts.splice(0).map((abort) => abort())) +}) + +async function createGuest() { + const { Window } = await import('happy-dom') + const window = new Window() + guestAborts.push(() => window.happyDOM.abort()) + const requestAnimationFrame = vi.fn(() => 17) + const cancelAnimationFrame = vi.fn() + const context = { window, document: window.document, requestAnimationFrame, cancelAnimationFrame } + const install = (enabled = true): void => { + runInNewContext( + buildBrowserAnnotationViewportBridgeScript({ + emitViewport: false, + enabled, + token: 'lifecycle-test-token', + markers: [ + { + id: 'saved-note', + index: 0, + isFixed: false, + rectPage: { x: 10, y: 20, width: 80, height: 30 }, + rectViewport: { x: 10, y: 20, width: 80, height: 30 } + } + ] + }), + context + ) + } + const markerHosts = (): number => + window.document.querySelectorAll('[data-orca-browser-annotation-overlay]').length + return { window, context, install, markerHosts, requestAnimationFrame, cancelAnimationFrame } +} + +describe('browser annotation guest document lifecycle', () => { + it('retires visible markers before a replacement document finishes loading', async () => { + const guest = await createGuest() + guest.install() + expect(guest.markerHosts()).toBe(1) + + guest.window.dispatchEvent(new guest.window.Event('beforeunload')) + + expect(guest.markerHosts()).toBe(0) + expect(Reflect.has(guest.context, '__orcaBrowserAnnotationViewportBridge')).toBe(false) + expect(guest.cancelAnimationFrame).toHaveBeenCalledWith(17) + guest.requestAnimationFrame.mockClear() + guest.window.dispatchEvent(new guest.window.Event('scroll')) + guest.window.document.dispatchEvent(new guest.window.Event('scroll')) + guest.window.dispatchEvent(new guest.window.Event('resize')) + expect(guest.requestAnimationFrame).not.toHaveBeenCalled() + }) + + it('removes the old unload listener when disabled and can install fresh markers', async () => { + const guest = await createGuest() + guest.install() + guest.install(false) + expect(guest.markerHosts()).toBe(0) + guest.install() + expect(guest.markerHosts()).toBe(1) + guest.cancelAnimationFrame.mockClear() + + guest.window.dispatchEvent(new guest.window.Event('beforeunload')) + + expect(guest.markerHosts()).toBe(0) + expect(guest.cancelAnimationFrame).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/shared/command-option-occurrence.ts b/src/shared/command-option-occurrence.ts index 6dee668829b..203e7252723 100644 --- a/src/shared/command-option-occurrence.ts +++ b/src/shared/command-option-occurrence.ts @@ -11,7 +11,7 @@ function matchesOption(token: string, aliases: readonly string[]): boolean { } export function findOptionOccurrence( - tokens: string[], + tokens: readonly string[], aliases: readonly string[], stopAtTerminator: boolean ): { index: number; consumed: number; value?: string } | null { diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index c079c724c60..e613231f445 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -293,7 +293,7 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) } // Why: the Codex glyph and opencode show-cursor signals must NOT arm the // quiet window (they carry no quiet anchor). opencode goes silent for - // ~1.5-2s between enabling bracketed paste and mounting its composer, so a + // Up to ~3.9s between enabling bracketed paste and mounting its composer, so a // quiet window would fire during that gap — before the composer exists — // and pre-empt the marker. Those signals wait for their marker, bounded // only by the caller's hard timeout (and its best-effort diff --git a/src/shared/dsb-headless-command.test.ts b/src/shared/dsb-headless-command.test.ts new file mode 100644 index 00000000000..cfd15d0cb46 --- /dev/null +++ b/src/shared/dsb-headless-command.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { isDsbHeadlessOneShotCommand } from './dsb-headless-command' + +describe('DeepSeek Build headless one-shot commands', () => { + it.each([ + ['-r', './preload.js'], + ['--require', './preload.js'], + ['--import', './preload.mjs'], + ['--loader', './loader.mjs'], + ['--experimental-loader', './loader.mjs'], + ['--require=./preload.js'], + ['--import=./preload.mjs'] + ])('finds run after Node options %j', (...options) => { + const shim = '/usr/lib/node_modules/@innocarpe/deepseek-build/npm/bin/dsb.js' + expect(isDsbHeadlessOneShotCommand(['node', ...options, shim, 'run', 'task'])).toBe(true) + expect(isDsbHeadlessOneShotCommand(['node', ...options, shim, 'agent'])).toBe(false) + }) + + it.each(['/usr/local/bin/node', 'C:\\Program Files\\nodejs\\node.exe'])( + 'recognizes the interpreter path %s before preload arguments', + (node) => { + const shim = 'C:\\Users\\dev\\node_modules\\@innocarpe\\deepseek-build\\npm\\bin\\dsb.js' + expect(isDsbHeadlessOneShotCommand([node, '-r', './preload.js', shim, 'run', 'task'])).toBe( + true + ) + } + ) + + it.each(['--cwd', '--preset', '--base-url', '--session', '--worktree-ref', '--effort'])( + 'finds run after the upstream value option %s', + (option) => { + expect(isDsbHeadlessOneShotCommand(['dsb', option, 'value', 'run', 'task'])).toBe(true) + expect(isDsbHeadlessOneShotCommand(['dsb', `${option}=value`, 'run', 'task'])).toBe(true) + expect(isDsbHeadlessOneShotCommand(['dsb', option, 'run', 'agent'])).toBe(false) + } + ) + + it('treats run as a one-shot and leaves the TUI interactive', () => { + expect(isDsbHeadlessOneShotCommand(['dsb', 'run', 'explain this'])).toBe(true) + expect(isDsbHeadlessOneShotCommand(['dsb', '--dogfood'])).toBe(false) + expect(isDsbHeadlessOneShotCommand(['dsb', 'agent'])).toBe(false) + expect(isDsbHeadlessOneShotCommand(['dsb', '--resume', 'sess-1'])).toBe(false) + expect( + isDsbHeadlessOneShotCommand([ + 'node', + '/usr/lib/node_modules/@innocarpe/deepseek-build/npm/bin/dsb.js', + 'run', + 'explain this' + ]) + ).toBe(true) + expect( + isDsbHeadlessOneShotCommand([ + 'node', + '/usr/lib/node_modules/@innocarpe/deepseek-build/npm/bin/dsb.js', + '--dogfood' + ]) + ).toBe(false) + }) +}) diff --git a/src/shared/dsb-headless-command.ts b/src/shared/dsb-headless-command.ts new file mode 100644 index 00000000000..ab239cc42a8 --- /dev/null +++ b/src/shared/dsb-headless-command.ts @@ -0,0 +1,121 @@ +import { + comparablePath, + findInterpreterEntrypointToken, + isInterpreterProcessName +} from './agent-command-line-entrypoint' + +const VALUE_OPTIONS = new Set([ + '--cwd', + '--preset', + '--base-url', + '--session', + '--worktree-ref', + '--effort', + '--model', + '-m', + '--rules', + '--append-system-prompt', + '--system-prompt', + '--system-prompt-override', + '--permission-mode', + '--output-format', + '--json-schema', + '--allow', + '--allowedTools', + '--deny', + '--disallowedTools', + '--reasoning-effort', + '--compaction-mode', + '--compaction-detail', + '--load', + '--session-id', + '-s', + '--ref', + '--agent', + '--agents', + '--tools', + '--disallowed-tools', + '--max-turns', + '--background-wait-timeout', + '--sandbox', + '--storage-mode', + '--client-identifier', + '--hunk-tracker-mode', + '--installer', + '--debug-file', + '--leader-socket' +]) + +const OPTIONAL_VALUE_OPTIONS = new Set(['--resume', '-r', '--worktree', '-w']) + +const ONE_SHOT_OPTIONS = new Set(['-p', '--single', '--print', '--prompt-json', '--prompt-file']) + +function isDsbScriptEntrypoint(token: string): boolean { + const base = token.split(/[\\/]/).pop()?.toLowerCase() ?? '' + return ( + base === 'dsb.js' || base === 'dsb.mjs' || base === 'dsb.cjs' || base === 'deepseek-build.js' + ) +} + +// Outer `agent` forwards native one-shot flags; preload arguments precede npm shims. +export function isDsbHeadlessOneShotCommand(tokens: readonly string[]): boolean { + const command = + comparablePath(tokens[0] ?? '') + .split('/') + .pop() + ?.replace(/\.(?:exe|cmd|bat|ps1)$/i, '') ?? '' + let commandIndex = 0 + if (isInterpreterProcessName(command)) { + const entrypoint = findInterpreterEntrypointToken([...tokens], command) + if (!entrypoint || !isDsbScriptEntrypoint(entrypoint)) { + return false + } + commandIndex = tokens.indexOf(entrypoint, 1) + } + let forwardsNativeArgs = false + let outerCommandSeen = command === 'deepseek-build-agent' + let outerSeparatorConsumed = false + for (let index = commandIndex + 1; index < tokens.length; index += 1) { + const token = tokens[index] + if (!token) { + return false + } + if (token === '--') { + // Outer trailing_var_arg consumes its first separator before native parsing. + if (forwardsNativeArgs && !outerSeparatorConsumed) { + outerSeparatorConsumed = true + continue + } + return false + } + const name = token.split('=', 1)[0] + if (ONE_SHOT_OPTIONS.has(name) || /^-c*p/.test(token)) { + return true + } + if (VALUE_OPTIONS.has(name) && !token.includes('=')) { + index += 1 + continue + } + if ( + OPTIONAL_VALUE_OPTIONS.has(name) && + !token.includes('=') && + tokens[index + 1] && + !tokens[index + 1].startsWith('-') + ) { + index += 1 + continue + } + if (token.startsWith('-')) { + continue + } + if (token === 'agent' && !outerCommandSeen) { + forwardsNativeArgs = true + outerCommandSeen = true + continue + } + if (!outerCommandSeen) { + return token === 'run' + } + } + return false +} diff --git a/src/shared/dsb-native-one-shot-recognition.test.ts b/src/shared/dsb-native-one-shot-recognition.test.ts new file mode 100644 index 00000000000..d6521452f52 --- /dev/null +++ b/src/shared/dsb-native-one-shot-recognition.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' +import { selectForegroundProcessCandidate } from './foreground-process-selection' + +// Released 6.9.0 PagerArgs single/JSON/file flags, forwarded by outer Commands::Agent. +describe('DeepSeek Build native one-shot identity exclusion', () => { + it.each([ + 'dsb agent -p task', + 'dsb agent -- --print task', + 'deepseek-build agent -- --prompt-file task.txt', + 'deepseek-build agent --single task', + 'dsb agent --print task', + 'dsb agent --prompt-json "[]"', + 'dsb agent --prompt-file /tmp/task.txt', + 'deepseek-build-agent -p task', + 'deepseek-build-agent.exe --print task', + 'deepseek-build-agent --single=task', + 'deepseek-build-agent -ptask', + 'deepseek-build-agent -cp task', + 'deepseek-build-agent -cptask', + 'deepseek-build-agent -p-', + 'deepseek-build-agent -cp--print', + 'dsb agent -p-', + 'dsb agent -cp--print', + 'dsb agent -cp task', + 'dsb run task', + 'deepseek-build-agent --resume saved-session --print task', + 'deepseek-build-agent --resume --prompt-file task.txt', + 'dsb --cwd folder agent --model deepseek-v4-flash --prompt-file task.txt', + 'node --import ./preload.mjs /x/node_modules/@innocarpe/deepseek-build/npm/bin/dsb.js agent --print task', + '"C:\\Program Files\\nodejs\\node.exe" --require preload.js "C:\\x\\node_modules\\@innocarpe\\deepseek-build\\npm\\bin\\deepseek-build.js" agent --prompt-json=[]' + ])('does not claim interactive ownership for %s', (line) => { + expect(recognizeAgentProcessFromCommandLine(line)).toBeNull() + expect( + recognizeAgentProcessFromCommandLine(line, { includeHeadlessOneShot: true })?.agent + ).toBe('dsb') + }) + + it.each([ + 'dsb agent "explain --print and -p"', + 'deepseek-build-agent -- "--print"', + 'dsb agent -- -- "--print"', + 'deepseek-build-agent --model=--print', + 'deepseek-build-agent --rules "use --prompt-file"', + 'deepseek-build-agent --system-prompt "run"', + 'dsb agent --model run', + 'dsb agent --resume saved-session', + 'deepseek-build-agent run', + 'dsb agent run', + 'deepseek-build-agent --leader-socket run', + 'deepseek-build-agent -mp', + 'deepseek-build-agent -rp', + 'deepseek-build-agent -sp', + 'deepseek-build-agent -wp', + 'deepseek-build-agent -cmp', + 'deepseek-build-agent -crp', + 'deepseek-build-agent -csp', + 'deepseek-build-agent -cwp' + ])('keeps interactive prompts and option values interactive: %s', (line) => { + expect(recognizeAgentProcessFromCommandLine(line)?.agent).toBe('dsb') + }) + + it('does not select a one-shot native child as interactive foreground', () => { + expect( + selectForegroundProcessCandidate([ + { + pid: 42, + ppid: 1, + depth: 1, + stat: 'S+', + command: '/tmp/bin/deepseek-build-agent --print task' + } + ]) + ).toBeNull() + }) +}) diff --git a/src/shared/dsb-terminal-title.test.ts b/src/shared/dsb-terminal-title.test.ts new file mode 100644 index 00000000000..061f1584b15 --- /dev/null +++ b/src/shared/dsb-terminal-title.test.ts @@ -0,0 +1,100 @@ +import { isGeminiTerminalTitle as isCoreGeminiTitle } from './agent-title-core' +import { detectAgentStatusFromTitle, normalizeTerminalTitle } from './agent-title-status' +import { describe, expect, it } from 'vitest' +import { isClaudeAgent as isIdentityClaudeAgent, getAgentLabel } from './agent-title-identity' +import { isDeepSeekBuildTerminalTitle } from './dsb-terminal-title' +import { + getAgentLabel as getTerminalTitleAgentLabel, + isClaudeAgent, + resolveTerminalTitleAgentType +} from './terminal-title-agent-type' +import { collectAgentTitleEvidence } from './agent-title-evidence' +import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter' + +const WORKING = '⠼ - Waiting for response… - DeepSeek Build' +const CLAUDE_MENTION = '⠋ Review DeepSeek Build integration' + +describe('DeepSeek Build terminal titles', () => { + it.each(['⠂', '⠐', '✦'])('keeps a native DSH %s title from becoming DSB', (prefix) => { + const title = `${prefix} 🐋 Review integration - DeepSeek Build` + expect(isDeepSeekBuildTerminalTitle(title)).toBe(false) + expect(resolveTerminalTitleAgentType(title)).toBe('dsh') + expect(collectAgentTitleEvidence(title).anchoredNames).not.toContain('dsb') + }) + + it.each(['Codex', 'Gemini', 'Claude', 'OpenCode', 'Grok', 'Cursor', 'Pi', 'Hermes'])( + 'keeps the DeepSeek Build owner when task text mentions %s', + (agent) => { + const title = `⠋ - Review ${agent} integration - DeepSeek Build` + expect(resolveTerminalTitleAgentType(title)).toBe('dsb') + expect(getAgentLabel(title)).toBe('DeepSeek Build') + expect(collectAgentTitleEvidence(title).agent).toBe('dsb') + expect(resolveCanonicalPaneAgentIdentity({ title }).agent).toBe('dsb') + expect(isClaudeAgent(title)).toBe(false) + expect(isIdentityClaudeAgent(title)).toBe(false) + } + ) + + it.each([ + ['✳ Review Codex - DeepSeek Build', 'claude', 'Claude Code'], + ['. Review Codex - DeepSeek Build', 'claude', 'Claude Code'], + ['* Review Codex - DeepSeek Build', 'claude', 'Claude Code'], + ['✦ Review Codex - DeepSeek Build', 'gemini', 'Gemini CLI'] + ])('preserves explicit vendor markers in %s', (title, agent, label) => { + expect(resolveTerminalTitleAgentType(title)).toBe(agent) + expect(getAgentLabel(title)).toBe(label) + }) + + it.each([ + '⠋ Review Codex integration - DeepSeek Build', + '⠋ Review integration - DeepSeek Build', + '◐ Review integration - DeepSeek Build' + ])('does not turn a Claude task suffix into DSB identity: %s', (title) => { + expect(isDeepSeekBuildTerminalTitle(title)).toBe(false) + expect(isClaudeAgent(title)).toBe(true) + expect(isIdentityClaudeAgent(title)).toBe(true) + expect(collectAgentTitleEvidence(title).anchoredNames).not.toContain('dsb') + expect(resolveCanonicalPaneAgentIdentity({ title }).agent).not.toBe('dsb') + }) + + it.each(['✦', '⏲', '◇', '✋'])('keeps task glyph %s from changing a DSB owner', (glyph) => { + const busy = `⠋ - Review ${glyph} rendering - DeepSeek Build` + for (const title of [busy, `zsh | ${busy}`, `⚠ Action Required - ${busy}`]) { + expect(isCoreGeminiTitle(title)).toBe(false) + expect(normalizeTerminalTitle(title)).toBe(title) + expect(detectAgentStatusFromTitle(title)).toBe( + title.startsWith('⚠') ? 'permission' : 'working' + ) + expect(resolveTerminalTitleAgentType(title)).toBe('dsb') + expect(getAgentLabel(title)).toBe('DeepSeek Build') + expect(getTerminalTitleAgentLabel(title)).toBe('DeepSeek Build') + expect(collectAgentTitleEvidence(title).agent).toBe('dsb') + expect(resolveCanonicalPaneAgentIdentity({ title }).agent).toBe('dsb') + } + }) + + it.each(['✦', '⏲', '◇', '✋'])('keeps idle task glyph %s from changing Build status', (glyph) => { + const title = `Review ${glyph} rendering - DeepSeek Build` + expect(detectAgentStatusFromTitle(title)).toBe('idle') + expect(normalizeTerminalTitle(title)).toBe(title) + }) + + it('matches the product segment and not a mention inside another task', () => { + expect(isDeepSeekBuildTerminalTitle('DeepSeek Build')).toBe(true) + expect(isDeepSeekBuildTerminalTitle('my-project - DeepSeek Build')).toBe(true) + expect(isDeepSeekBuildTerminalTitle(WORKING)).toBe(true) + expect(isDeepSeekBuildTerminalTitle(CLAUDE_MENTION)).toBe(false) + expect(isDeepSeekBuildTerminalTitle('Warning: DeepSeek Build')).toBe(false) + }) + + it('keeps a Claude task that mentions DeepSeek Build on both title classifiers', () => { + expect(isClaudeAgent(CLAUDE_MENTION)).toBe(true) + expect(isIdentityClaudeAgent(CLAUDE_MENTION)).toBe(true) + expect(resolveTerminalTitleAgentType(CLAUDE_MENTION)).toBe('claude') + expect(getAgentLabel(CLAUDE_MENTION)).toBe('Claude Code') + expect(resolveTerminalTitleAgentType(WORKING)).toBe('dsb') + expect(getAgentLabel(WORKING)).toBe('DeepSeek Build') + expect(isClaudeAgent(WORKING)).toBe(false) + expect(isIdentityClaudeAgent(WORKING)).toBe(false) + }) +}) diff --git a/src/shared/dsb-terminal-title.ts b/src/shared/dsb-terminal-title.ts new file mode 100644 index 00000000000..45207184f01 --- /dev/null +++ b/src/shared/dsb-terminal-title.ts @@ -0,0 +1,55 @@ +import { + CLAUDE_IDLE, + GEMINI_IDLE, + GEMINI_PERMISSION, + GEMINI_SILENT_WORKING, + GEMINI_WORKING, + containsAgentSpinnerGlyph +} from './agent-title-glyphs' +import { isDshTerminalTitle } from './dsh-terminal-title' +import type { AgentStatus } from './agent-title-core' +import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' + +const DSB_TITLE_RE = /(?:^| - )deepseek build$/i +// Why: DSB separates its spinner with " - "; a Claude task can end on the same suffix. +const DSB_WORKING_TITLE_RE = + /^(?:⚠ Action Required - )?[\u2800-\u28ff]+\s+-\s+[\s\S]+?\s-\s+deepseek build$/i +const NATIVE_VENDOR_PREFIXES = [ + `${CLAUDE_IDLE} `, + '. ', + '* ', + GEMINI_IDLE, + GEMINI_PERMISSION, + GEMINI_SILENT_WORKING, + GEMINI_WORKING +] + +export function isDeepSeekBuildTerminalTitle(title: string): boolean { + const segments = getWrapperTitleSegments(title.trim()) + // Why: native owner markers must win even inside a wrapper; task glyphs are not markers. + if ( + segments.some( + (segment) => + isDshTerminalTitle(segment) || + NATIVE_VENDOR_PREFIXES.some((prefix) => segment.startsWith(prefix)) + ) + ) { + return false + } + return segments.some( + (segment) => + DSB_TITLE_RE.test(segment) && + (!containsAgentSpinnerGlyph(segment) || DSB_WORKING_TITLE_RE.test(segment)) + ) +} + +export function getDeepSeekBuildTitleStatus(title: string): AgentStatus | null { + if (!isDeepSeekBuildTerminalTitle(title)) { + return null + } + const segments = getWrapperTitleSegments(title.trim()) + if (segments.some((segment) => segment.startsWith('⚠ Action Required - '))) { + return 'permission' + } + return segments.some((segment) => DSB_WORKING_TITLE_RE.test(segment)) ? 'working' : 'idle' +} diff --git a/src/shared/flag-prompt-startup.ts b/src/shared/flag-prompt-startup.ts new file mode 100644 index 00000000000..50e1be306fa --- /dev/null +++ b/src/shared/flag-prompt-startup.ts @@ -0,0 +1,50 @@ +import type { AgentStartupPlan } from './tui-agent-startup' +import type { SleepingAgentLaunchConfig } from './agent-session-resume' +import type { SessionOptionValue } from './native-chat-session-options' +import type { TuiAgent } from './tui-agent' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import { findOpenCodeRunCommand } from './opencode-headless-command' +import { openCodeStartupPromptEnv } from './opencode-startup-prompt' + +export function appliedSessionOptionProps(values: Record) { + return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} +} + +export function buildFlagPromptStartupPlan(args: { + agent: TuiAgent + launchCommand: string + quotedPrompt: string + prompt: string + shell: AgentStartupShell + launchConfig: SleepingAgentLaunchConfig + sessionOptions: Record + agentEnv: Record | null | undefined +}): AgentStartupPlan { + const parsed = tokenizeStartupCommand(args.launchCommand, args.shell) + const openCodeRun = + (args.agent === 'opencode' || args.agent === 'opencode2') && parsed.ok + ? findOpenCodeRunCommand(parsed.tokens, args.shell) + : null + // OpenCode run takes a positional message; --prompt belongs to its TUI. + const promptSuffix = openCodeRun + ? openCodeRun.messageSeparatorIndex !== null + ? ` ${args.quotedPrompt}` + : ` -- ${args.quotedPrompt}` + : ` --prompt ${args.quotedPrompt}` + return { + agent: args.agent, + launchCommand: `${args.launchCommand}${promptSuffix}`, + expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess, + followupPrompt: null, + launchConfig: args.launchConfig, + ...appliedSessionOptionProps(args.sessionOptions), + ...openCodeStartupPromptEnv( + args.agent, + args.launchCommand, + args.shell, + args.prompt, + args.agentEnv + ) + } +} diff --git a/src/shared/json-token-reader.test.ts b/src/shared/json-token-reader.test.ts new file mode 100644 index 00000000000..9cba3e8a869 --- /dev/null +++ b/src/shared/json-token-reader.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import { FlexAssembler } from 'stream-json/core/utils/flex-assembler.js' +import { createJsonTokenReader } from './json-token-reader' + +it.each([1, 7, 64 * 1024, Infinity])('preserves Unicode at chunk size %s', (size) => { + const value = { '\ufeffkey': `\ufeffstart${'\n'.repeat(64 * 1024)}\ufeff😀end` } + const literal = JSON.stringify(value) + for (const text of [literal, literal.replaceAll('\ufeff', '\\uFEFF')]) { + const assembler = new FlexAssembler() + const reader = createJsonTokenReader((token) => { + assembler.consume(token) + }) + for (let offset = 0; offset < text.length; offset += size) { + reader.write(text.slice(offset, offset + size)) + } + reader.end() + expect(assembler.current).toEqual(value) + } +}) + +it.each(['', ' ', '{', '{"a":1,}', '{}{}', '[01]', '\ufeff{}'])( + 'rejects malformed JSON %j as a syntax error', + (text) => { + const reader = createJsonTokenReader(() => {}) + expect(() => { + reader.write(text) + reader.end() + }).toThrow(SyntaxError) + } +) + +it('preserves consumer errors and completes numbers at EOF synchronously', () => { + const values: string[] = [] + const reader = createJsonTokenReader((token) => { + if (token.name === 'numberValue') { + values.push(token.value) + } + }) + reader.write('12') + reader.end() + expect(values).toEqual(['12']) + const failure = new RangeError('consumer failure') + const throwing = createJsonTokenReader(() => { + throw failure + }) + expect(() => throwing.write('{}')).toThrow(failure) +}) diff --git a/src/shared/json-token-reader.ts b/src/shared/json-token-reader.ts new file mode 100644 index 00000000000..c0081ee016f --- /dev/null +++ b/src/shared/json-token-reader.ts @@ -0,0 +1,48 @@ +import parser, { type Token } from 'stream-json/core/parser.js' +import exec from 'stream-chain/exec.js' +import { none } from 'stream-chain/defs.js' + +/** Bounds token batches while preserving caller-owned UTF-8 decoding. */ +export function createJsonTokenReader( + consume: (token: Token) => void, + batchCodeUnits = 64 * 1024 +): { + write: (text: string) => void + end: () => void +} { + if (!Number.isSafeInteger(batchCodeUnits) || batchCodeUnits < 1) { + throw new RangeError('JSON token batch size must be a positive safe integer') + } + const parse = exec(parser({ streamValues: false })) + function run(input: string | typeof none): void { + let consumerFailed = false + try { + const pending = parse(input, (token: Token) => { + try { + consume(token) + } catch (error) { + consumerFailed = true + throw error + } + }) + if (pending) { + throw new Error('JSON token reader requires a synchronous parser') + } + } catch (error) { + if (!consumerFailed && error instanceof Error) { + throw new SyntaxError(error.message) + } + throw error + } + } + return { + write(text) { + for (let offset = 0; offset < text.length; offset += batchCodeUnits) { + run(text.slice(offset, offset + batchCodeUnits)) + } + }, + end() { + run(none) + } + } +} diff --git a/src/shared/keybindings-unassigned-actions.test.ts b/src/shared/keybindings-unassigned-actions.test.ts index 007f1074bca..a591e20fb76 100644 --- a/src/shared/keybindings-unassigned-actions.test.ts +++ b/src/shared/keybindings-unassigned-actions.test.ts @@ -215,6 +215,36 @@ describe('keybindings', () => { ) }) + it('keeps the child-workspaces toggle unassigned until users customize it', () => { + const binding = { + key: 'h', + code: 'KeyH', + control: true, + meta: false, + alt: true, + shift: false + } + + for (const platform of ['darwin', 'linux', 'win32'] as const) { + expect(getEffectiveKeybindingsForAction('sidebar.childWorkspaces.toggle', platform)).toEqual( + [] + ) + } + expect(keybindingMatchesAction('sidebar.childWorkspaces.toggle', binding, 'linux')).toBe(false) + expect( + keybindingMatchesAction('sidebar.childWorkspaces.toggle', binding, 'linux', { + 'sidebar.childWorkspaces.toggle': ['Mod+Alt+H'] + }) + ).toBe(true) + + const definition = getKeybindingDefinition('sidebar.childWorkspaces.toggle') + expect(definition?.title).toBe('Toggle Child Workspaces') + expect(definition?.scope).toBe('global') + expect(definition?.searchKeywords).toEqual( + expect.arrayContaining(['child', 'children', 'collapse', 'expand']) + ) + }) + it('leaves floating workspace minimize unassigned because floating terminal toggle owns show and hide', () => { const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] const minimizeAction = 'floatingWorkspace.minimize' as KeybindingActionId diff --git a/src/shared/keybindings/definitions-core-4.ts b/src/shared/keybindings/definitions-core-4.ts index 9ed02c02d86..acba537dd6c 100644 --- a/src/shared/keybindings/definitions-core-4.ts +++ b/src/shared/keybindings/definitions-core-4.ts @@ -2,6 +2,28 @@ import type { KeybindingDefinition } from './types' import { platformBindings } from './definitions-support' export const KEYBINDING_DEFINITION_CORE_4: readonly KeybindingDefinition[] = [ + { + id: 'sidebar.childWorkspaces.toggle', + title: 'Toggle Child Workspaces', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'sidebar', + 'child', + 'children', + 'nested', + 'lineage', + 'workspaces', + 'worktree', + 'collapse', + 'expand', + 'show', + 'hide' + ], + // Why: ship unbound like the sleeping toggle, so no existing chord is claimed on any platform. + defaultBindings: platformBindings([]) + }, { id: 'terminal.clearPaneTitle', title: 'Clear Pane Title', diff --git a/src/shared/keybindings/types.ts b/src/shared/keybindings/types.ts index bcbd765d0e6..7098d14d659 100644 --- a/src/shared/keybindings/types.ts +++ b/src/shared/keybindings/types.ts @@ -47,6 +47,7 @@ export type KeybindingActionId = | 'sidebar.checks.toggle' | 'sidebar.ports.toggle' | 'sidebar.sleepingWorkspaces.toggle' + | 'sidebar.childWorkspaces.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' | 'floatingWorkspace.maximize' diff --git a/src/shared/native-chat-merge-pairs.test.ts b/src/shared/native-chat-merge-pairs.test.ts new file mode 100644 index 00000000000..fe59e24334a --- /dev/null +++ b/src/shared/native-chat-merge-pairs.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + replaceList +} from './native-chat-merge' +import type { NativeChatMessage } from './native-chat-types' + +function row(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + source: 'transcript', + timestamp: 1, + blocks: [{ type: 'text', text: id }], + ...overrides + } +} + +function pair(id: string): NativeChatMessage[] { + return [row(`${id}:reasoning`, { role: 'reasoning' }), row(id)] +} + +describe('native chat complete-pair windows', () => { + it.each(['msg-provider', 'opencode:msg-provider'])('keeps the cutoff pair for %s', (id) => { + const messages = [row('older'), ...pair(id), row('latest')] + expect(boundNativeChatWindow(messages, 2)).toEqual(messages.slice(1)) + expect(messages.map((message) => message.id)).toEqual([ + 'older', + `${id}:reasoning`, + id, + 'latest' + ]) + }) + + it('keeps the newest complete pair even at limit 1', () => { + const messages = [...pair('older'), ...pair('latest')] + expect(boundNativeChatWindow(messages, 1)).toEqual(messages.slice(2)) + }) + + it('does not group unrelated reasoning, suffixes, or user rows', () => { + const unrelated = [row('thinking', { role: 'reasoning' }), row('answer'), row('latest')] + expect(boundNativeChatWindow(unrelated, 2)).toEqual(unrelated.slice(1)) + const suffixOnly = [row('answer:reasoning'), row('answer'), row('latest')] + expect(boundNativeChatWindow(suffixOnly, 2)).toEqual(suffixOnly.slice(1)) + const user = [row('prompt:reasoning', { role: 'reasoning' }), row('prompt', { role: 'user' })] + expect(boundNativeChatWindow(user, 1)).toEqual(user.slice(1)) + }) + + it('keeps ordering, source precedence and cached indexes after paired trims', () => { + const merger = createNativeChatMerger() + const [reasoning, answer] = pair('opencode:first') + if (!reasoning || !answer) { + throw new Error('Expected a pair') + } + replaceList(merger, [row('older'), reasoning, { ...answer, source: 'hook' }]) + const authoritative = { ...answer, blocks: [{ type: 'text' as const, text: 'final answer' }] } + const newest = row('newest') + expect(applyAppend(merger, [authoritative, newest], 2)).toEqual([ + reasoning, + authoritative, + newest + ]) + expect([...merger.indexById]).toEqual([ + [reasoning.id, 0], + [answer.id, 1], + ['newest', 2] + ]) + expect(applyAppend(merger, [{ ...answer, source: 'scrape' }], 2)[1]).toBe(authoritative) + expect(applyAppend(merger, pair('opencode:next'), 2).map((message) => message.id)).toEqual([ + 'opencode:next:reasoning', + 'opencode:next' + ]) + expect([...merger.indexById]).toEqual([ + ['opencode:next:reasoning', 0], + ['opencode:next', 1] + ]) + }) + + it.each([1, 40, 2000])('stays at most one row above limit %i across long appends', (limit) => { + const merger = createNativeChatMerger() + for (let index = 0; index < limit + 20; index++) { + const messages = applyAppend( + merger, + index % 2 === 0 ? pair(`opencode:${index}`) : [row(`user-${index}`, { role: 'user' })], + limit + ) + expect(messages.length).toBeLessThanOrEqual(limit + 1) + expect(messages[0]?.role).not.toBe('assistant') + expect(merger.indexById.size).toBe(messages.length) + } + for (let at = 0; at < merger.list.length; at++) { + const message = merger.list[at] + expect(merger.indexById.get(message.id)).toBe(at) + if (message.role === 'reasoning') { + expect(merger.list[at + 1]?.id).toBe(message.id.slice(0, -':reasoning'.length)) + } else if (message.role === 'assistant') { + expect(merger.list[at - 1]?.id).toBe(`${message.id}:reasoning`) + } + } + }) +}) + +it('retains reasoning, answer and omission projections sharing an optional raw-row cursor', () => { + const messages = [ + row('older'), + ...pair('mixed').map((message) => ({ ...message, transcriptOffset: 42 })), + row('mixed:omission', { role: 'system', transcriptOffset: 42 }), + row('latest', { transcriptOffset: 43 }) + ] + expect(boundNativeChatWindow(messages, 2)).toEqual(messages.slice(1)) + expect(boundNativeChatWindow(messages, 1)).toEqual(messages.slice(-1)) +}) diff --git a/src/shared/native-chat-merge.ts b/src/shared/native-chat-merge.ts index a2e12ad8302..310634af4b3 100644 --- a/src/shared/native-chat-merge.ts +++ b/src/shared/native-chat-merge.ts @@ -1,6 +1,7 @@ // Pure id-dedup and windowing for both desktop and mobile native-chat streams. import { + nativeChatMessagesShareTranscriptRow, NATIVE_CHAT_SOURCE_PRIORITY, type NativeChatMessage, type NativeChatSource @@ -35,10 +36,7 @@ export function mergeNativeChatMessages( return mergeNativeChatMessagesWith(existing, incoming, NATIVE_CHAT_SOURCE_PRIORITY) } -/** Cap a message list to its most-recent `limit` entries. The base read is - * already windowed; this keeps the live-append tail bounded to the same window - * so a long run can't grow the list without limit. A non-positive limit means - * "no cap". Returns the input reference when no trim is needed. */ +/** Keep the recent tail without splitting a provider row. A non-positive limit means no cap. */ export function boundNativeChatWindow( messages: readonly NativeChatMessage[], limit: number @@ -46,7 +44,11 @@ export function boundNativeChatWindow( if (limit <= 0 || messages.length <= limit) { return messages as NativeChatMessage[] } - return messages.slice(messages.length - limit) + let start = messages.length - limit + while (start > 0 && nativeChatMessagesShareTranscriptRow(messages[start - 1], messages[start])) { + start -= 1 + } + return messages.slice(start) } /** Stateful id-dedup merger that caches the id→index map across appends so a diff --git a/src/shared/native-chat-reasoning-order.test.ts b/src/shared/native-chat-reasoning-order.test.ts new file mode 100644 index 00000000000..ea3fd56305b --- /dev/null +++ b/src/shared/native-chat-reasoning-order.test.ts @@ -0,0 +1,46 @@ +import { expect, it } from 'vitest' +import type { NativeChatMessage } from './native-chat-types' +import { + compareNativeChatTranscriptMessages, + projectNativeChatTranscriptMessages +} from './native-chat-transcript-projection' + +function row(id: string, role: NativeChatMessage['role'], timestamp = 1): NativeChatMessage { + return { id, role, timestamp, source: 'transcript', blocks: [{ type: 'text', text: id }] } +} + +it('keeps derived reasoning immediately before its answer without changing provider timestamps', () => { + const messages = [ + row('opencode:msg_b', 'assistant'), + row('opencode:msg_a:other', 'assistant'), + row('opencode:msg_a', 'assistant'), + row('opencode:msg_b:reasoning', 'reasoning'), + row('opencode:msg_a:reasoning', 'reasoning') + ] + const sorted = [...messages].sort(compareNativeChatTranscriptMessages) + expect(sorted.map((message) => message.id)).toEqual([ + 'opencode:msg_a:reasoning', + 'opencode:msg_a', + 'opencode:msg_a:other', + 'opencode:msg_b:reasoning', + 'opencode:msg_b' + ]) + expect(projectNativeChatTranscriptMessages(messages)).toEqual(sorted) + expect(messages.every((message) => message.timestamp === 1)).toBe(true) + for (let first = 0; first < sorted.length; first++) { + for (let second = first + 1; second < sorted.length; second++) { + expect(compareNativeChatTranscriptMessages(sorted[first], sorted[second])).toBeLessThan(0) + expect(compareNativeChatTranscriptMessages(sorted[second], sorted[first])).toBeGreaterThan(0) + } + } +}) + +it('keeps timestamp and journal authority ahead of a derived reasoning key', () => { + const answer = row('row', 'assistant', 1) + const laterReasoning = row('row:reasoning', 'reasoning', 2) + expect(compareNativeChatTranscriptMessages(answer, laterReasoning)).toBeLessThan(0) + answer.journalPosition = { sequence: 1, index: 0 } + laterReasoning.journalPosition = { sequence: 2, index: 0 } + laterReasoning.timestamp = 0 + expect(compareNativeChatTranscriptMessages(answer, laterReasoning)).toBeLessThan(0) +}) diff --git a/src/shared/native-chat-transcript-projection.ts b/src/shared/native-chat-transcript-projection.ts index d826ecaf552..3ab78007edb 100644 --- a/src/shared/native-chat-transcript-projection.ts +++ b/src/shared/native-chat-transcript-projection.ts @@ -7,7 +7,7 @@ // conversation: each subagent's rows are that subagent's, kept apart and folded on // their own, for the surface that shows them beside the spawn that ran it. -import type { NativeChatMessage } from './native-chat-types' +import { nativeChatSemanticRowId, type NativeChatMessage } from './native-chat-types' import { compareAgentJournalPositions } from './agent-session-journal-position' import { agentJournalItemSubagentId } from './agent-session-journal-producer' import { stripNoiseMessages } from './native-chat-noise' @@ -25,13 +25,18 @@ export function compareNativeChatMessagesByTime( if (at !== bt) { return at - bt } - if (a.id < b.id) { + // Split reasoning shares its provider row's key, before that row's answer. + const aId = nativeChatSemanticRowId(a) + const bId = nativeChatSemanticRowId(b) + const aReasoning = aId !== a.id + const bReasoning = bId !== b.id + if (aId < bId) { return -1 } - if (a.id > b.id) { + if (aId > bId) { return 1 } - return 0 + return Number(bReasoning) - Number(aReasoning) } /** Rows the journal holds read in the journal's own order, never its clock: a diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index db7b7024cdc..127a9837910 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -205,6 +205,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { * supply one (e.g. some scrape segments). Null sorts before any timestamp. */ timestamp: number | null source: NativeChatSource + /** Optional provider row cursor; split projections share it for whole-row paging. */ + transcriptOffset?: number /** Optional explicit turn key. When present, two messages with the same * `turnId` are treated as the same turn for dedup regardless of `id`. */ turnId?: string @@ -223,6 +225,28 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { foldedJournalPosition?: AgentJournalPosition } +/** Split reasoning and its answer share the provider's row identity. */ +export function nativeChatSemanticRowId(message: NativeChatMessage): string { + return message.role === 'reasoning' && message.id.endsWith(':reasoning') + ? message.id.slice(0, -':reasoning'.length) + : message.id +} + +/** New hosts expose the cursor; older messages retain their reasoning/answer id convention. */ +export function nativeChatMessagesShareTranscriptRow( + first: NativeChatMessage, + second: NativeChatMessage +): boolean { + if (typeof first.transcriptOffset === 'number' && typeof second.transcriptOffset === 'number') { + return first.transcriptOffset === second.transcriptOffset + } + return ( + first.role === 'reasoning' && + second.role === 'assistant' && + nativeChatSemanticRowId(first) === second.id + ) +} + export const NATIVE_CHAT_TURN_LIFECYCLE_STATES = ['working', 'completed', 'interrupted'] as const export type NativeChatTurnLifecycleState = (typeof NATIVE_CHAT_TURN_LIFECYCLE_STATES)[number] diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts new file mode 100644 index 00000000000..cc9c5987f2e --- /dev/null +++ b/src/shared/opencode-cli-version.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest' +import { getOpenCodeCliCapabilities, parseOpenCodeCliVersion } from './opencode-cli-version' + +it.each(['1.1.23', 'opencode v2.0.16', '2.0.0-beta.1+build'])('parses CLI output %s', (output) => { + expect(parseOpenCodeCliVersion(output)).toBe(output.replace(/^opencode v/, '')) +}) +it.each([null, '', 'Error 2.0.16', '1.1', '2.0.16\nwarning'])( + 'does not mistake other output for a version', + (output) => { + expect(parseOpenCodeCliVersion(output)).toBeNull() + } +) +it('keeps future versions unknown instead of assuming a plugin loader or prompt policy', () => { + expect(getOpenCodeCliCapabilities('3.0.0')).toEqual({ + version: '3.0.0', + pluginApi: 'unknown', + promptMode: 'unknown' + }) + expect(getOpenCodeCliCapabilities('1.1.23')).toEqual({ + version: '1.1.23', + pluginApi: 'v1', + promptMode: 'submit' + }) + expect(getOpenCodeCliCapabilities('opencode v2.0.16')).toEqual({ + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) +}) + +it('does not opt unverified v2 builds into an extra prompt submission', () => { + expect(getOpenCodeCliCapabilities('2.0.17').promptMode).toBe('unknown') + expect(getOpenCodeCliCapabilities('2.0.16-beta').promptMode).toBe('unknown') +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts new file mode 100644 index 00000000000..2aa71b2c30f --- /dev/null +++ b/src/shared/opencode-cli-version.ts @@ -0,0 +1,26 @@ +import { isValidAppVersion } from './app-version' + +export type OpenCodeCliCapabilities = { + version: string | null + pluginApi: 'v1' | 'v2' | 'unknown' + promptMode: 'submit' | 'prefill' | 'unknown' +} + +export function parseOpenCodeCliVersion(output: string | null | undefined): string | null { + const version = output + ?.trim() + .match(/^(?:opencode\s+)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/i)?.[1] + return version && isValidAppVersion(version) ? version : null +} + +export function getOpenCodeCliCapabilities( + output: string | null | undefined +): OpenCodeCliCapabilities { + const version = parseOpenCodeCliVersion(output) + const major = version?.split('.')[0] + return { + version, + pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', + promptMode: major === '1' ? 'submit' : version === '2.0.16' ? 'prefill' : 'unknown' + } +} diff --git a/src/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts index 408895a1dbb..09d243a4bcf 100644 --- a/src/shared/opencode-headless-command.test.ts +++ b/src/shared/opencode-headless-command.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' import { tokenizeCommandLine } from './agent-command-line-entrypoint' import { isOpenCodeRunCommand } from './opencode-headless-command' @@ -24,3 +25,66 @@ describe('isOpenCodeRunCommand', () => { expect(matches('opencode --log-level run')).toBe(false) }) }) + +describe('wrapped OpenCode run command position', () => { + it.each([ + 'CUSTOM_CONFIG=private opencode --log-level debug run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'CUSTOM_CONFIG=private /usr/bin/env -- EXTRA_CONFIG=kept opencode run --standalone' + ])('recognizes only the executable behind supported POSIX prefixes: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it('recognizes a PowerShell call operator before a quoted executable', () => { + const parsed = tokenizeStartupCommand( + '& "C:\\Program Files\\opencode\\opencode.exe" run --standalone', + 'powershell' + ) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(isOpenCodeRunCommand(parsed.tokens, 'powershell')).toBe(true) + expect(isOpenCodeRunCommand(parsed.tokens, 'cmd')).toBe(false) + }) + + it.each([ + 'env -u FOO opencode run', + 'env -uFOO opencode run', + 'env --unset FOO opencode run', + 'env --unset=FOO opencode run', + 'env -i PRIVATE_CONFIG=kept opencode run', + 'env --ignore-environment PRIVATE_CONFIG=kept opencode run', + 'env - PRIVATE_CONFIG=kept opencode run', + 'env -C /workspace opencode run', + 'env -C/workspace opencode run', + 'env --chdir /workspace opencode run', + 'env --chdir=/workspace opencode run', + 'env -P /private/bin opencode run', + 'env -P/private/bin opencode run', + 'CONFIG=kept /usr/bin/env -i -u FOO -C /workspace -- OTHER=kept opencode run' + ])('recognizes an executable after env options: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it.each([ + 'env -u', + 'env -C', + 'env -u opencode run', + 'env -C opencode run', + 'env -u FOO echo opencode run', + 'env --unset=FOO echo run', + 'env -S "opencode run"', + 'env --unknown opencode run' + ])('does not mistake env option arguments for the executable: %s', (command) => { + expect(matches(command)).toBe(false) + }) + + it('does not find executable names inside other commands or prompt arguments', () => { + expect(matches('env CUSTOM_CONFIG=private echo opencode run')).toBe(false) + expect(matches('env CUSTOM_CONFIG=private echo run')).toBe(false) + expect(matches('CUSTOM_CONFIG=private opencode --prompt "opencode run"')).toBe(false) + expect(matches('env -- opencode serve --title run')).toBe(false) + expect(matches('opencode attach http://host/run')).toBe(false) + }) +}) diff --git a/src/shared/opencode-headless-command.ts b/src/shared/opencode-headless-command.ts index 5328f7931fc..766d70642fa 100644 --- a/src/shared/opencode-headless-command.ts +++ b/src/shared/opencode-headless-command.ts @@ -1,16 +1,105 @@ -// Why: `opencode run` answers one prompt and exits, so its process lifetime is its turn. -// Not in agent-headless-command's table: that would also drop OpenCode 1 `run`'s identity, -// whose in-process plugin reports it. Only `--log-level` takes a separate value before the -// subcommand; any other valued option makes the value the first positional, which fails safe. -export function isOpenCodeRunCommand(tokens: readonly string[]): boolean { - for (let index = 1; index < tokens.length; index += 1) { +import { extractLeadingEnvAssignments } from './command-environment' +import { getCommandTokenPathBasename } from './command-token-scanner' +import type { AgentStartupShell } from './tui-agent-startup-shell' + +const RUN_VALUE_FLAGS = new Set([ + '--log-level', + '--completions', + '--server', + '--session', + '-s', + '--model', + '-m', + '--agent', + '--format', + '--file', + '-f', + '--title' +]) + +const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir', '-P']) +const ENV_EMPTY_FLAGS = new Set(['-i', '--ignore-environment', '-']) + +function envCommandPosition(tokens: readonly string[], offset: number): number { + let index = offset + while (index < tokens.length) { + const token = tokens[index] + if (token === '--') { + index += 1 + break + } + if (ENV_EMPTY_FLAGS.has(token)) { + index += 1 + } else if (ENV_VALUE_FLAGS.has(token)) { + index += 2 + } else if (/^(?:-[uCP].+|--(?:unset|chdir)=)/.test(token)) { + index += 1 + } else if (token.startsWith('-')) { + // Split-string options need another parse before their executable is known. + return tokens.length + } else { + break + } + } + return tokens.length - extractLeadingEnvAssignments(tokens.slice(index)).rest.length +} + +function openCodeCommandPosition(tokens: readonly string[], shell: AgentStartupShell): number { + if (shell === 'powershell' && tokens[0] === '&') { + return 1 + } + if (shell !== 'posix') { + return 0 + } + let index = tokens.length - extractLeadingEnvAssignments(tokens.slice()).rest.length + if (getCommandTokenPathBasename(tokens[index] ?? '') === 'env') { + index = envCommandPosition(tokens, index + 1) + } + return index +} + +export function findOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): { runIndex: number; messageSeparatorIndex: number | null } | null { + const commandPosition = openCodeCommandPosition(tokens, shell) + if (commandPosition > 0) { + const binary = getCommandTokenPathBasename(tokens[commandPosition] ?? '') + .toLowerCase() + .replace(/\.(?:exe|cmd)$/, '') + if (binary !== 'opencode' && binary !== 'opencode2') { + return null + } + } + for (let index = commandPosition + 1; index < tokens.length; index += 1) { const token = tokens[index] if (!token.startsWith('-')) { - return token === 'run' + if (token !== 'run') { + return null + } + for (let argumentIndex = index + 1; argumentIndex < tokens.length; argumentIndex += 1) { + const argument = tokens[argumentIndex] + if (argument === '--') { + return { runIndex: index, messageSeparatorIndex: argumentIndex } + } + if (RUN_VALUE_FLAGS.has(argument)) { + argumentIndex += 1 + } + } + return { runIndex: index, messageSeparatorIndex: null } } + // Other valued global options fail closed at their first positional value. if (token === '--log-level') { index += 1 } } - return false + return null +} + +// OpenCode run's process lifetime is its turn; v1 still reports through its plugin. +export function isOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): boolean { + return findOpenCodeRunCommand(tokens, shell) !== null } diff --git a/src/shared/opencode-startup-prompt-install.ts b/src/shared/opencode-startup-prompt-install.ts new file mode 100644 index 00000000000..6427a9e9886 --- /dev/null +++ b/src/shared/opencode-startup-prompt-install.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path' +import { writeOpenCodeTuiPluginDirectory } from './opencode-tui-plugin-install' + +export const OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY = 'orca-opencode-startup-prompt' + +export function writeOpenCodeStartupPromptPlugin( + configDir: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + writeOpenCodeTuiPluginDirectory( + join(configDir, 'plugins'), + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + source, + ownership + ) +} diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts new file mode 100644 index 00000000000..286f7e3ad2f --- /dev/null +++ b/src/shared/opencode-startup-prompt.test.ts @@ -0,0 +1,157 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan, buildAgentDraftLaunchPlan } from './tui-agent-startup' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from './opencode-startup-prompt' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' + +describe('native OpenCode startup submission intent', () => { + it('binds the exact trimmed native prompt to host-selectable transport', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: ' task\nwith unicode é ', + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.env).toEqual({ + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256') + .update('task\nwith unicode é') + .digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task\nwith unicode é', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }) + expect(plan?.launchCommand).toContain('--prompt') + }) + + it('preserves explicit run commands without a submission intent', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: 'task', + cmdOverrides: { opencode: 'opencode --log-level debug run' }, + platform: 'linux' + }) + expect(plan?.env).toBeUndefined() + expect(plan?.launchCommand).toContain("run -- 'task'") + }) + + it.each(['posix', 'powershell', 'cmd'] as const)( + 'keeps run and its flags before a positional message in %s', + (shell) => { + for (const agent of ['opencode', 'opencode2'] as const) { + const prompt = '--literal task with unicode é' + const plan = buildAgentStartupPlan({ + agent, + prompt, + cmdOverrides: { [agent]: 'opencode --log-level debug run --standalone' }, + platform: shell === 'posix' ? 'linux' : 'win32', + shell, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan).not.toBeNull() + const parsed = tokenizeStartupCommand(plan?.launchCommand ?? '', shell) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(parsed.tokens).toEqual([ + 'opencode', + '--log-level', + 'debug', + 'run', + '--standalone', + '--', + prompt + ]) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['posix', 'powershell', 'cmd'] as const)( + 'reuses an existing run message separator in %s', + (shell) => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: '--literal task', + cmdOverrides: { opencode: 'opencode run --standalone --' }, + platform: shell === 'posix' ? 'linux' : 'win32', + shell + }) + const parsed = tokenizeStartupCommand(plan?.launchCommand ?? '', shell) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(parsed.tokens).toEqual(['opencode', 'run', '--standalone', '--', '--literal task']) + expect(plan?.env).toBeUndefined() + } + ) + + it('never gives an editable draft or empty launch an automatic submission intent', () => { + const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const } + expect( + buildAgentDraftLaunchPlan({ ...args, draft: 'draft' })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + expect( + buildAgentStartupPlan({ ...args, prompt: '', allowEmptyPromptLaunch: true })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + }) +}) + +describe('wrapped OpenCode run startup', () => { + it.each(['opencode', 'opencode2'] as const)( + 'keeps %s run flags and positional task behind POSIX prefixes', + (agent) => { + for (const command of [ + 'CUSTOM_CONFIG=private opencode run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone --', + 'env CUSTOM_CONFIG=private opencode run --title "--"' + ]) { + const plan = buildAgentStartupPlan({ + agent, + prompt: '--literal task', + cmdOverrides: { [agent]: command }, + platform: 'linux', + shell: 'posix', + isRemote: true, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe( + command.endsWith(' --') ? `${command} '--literal task'` : `${command} -- '--literal task'` + ) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['opencode', 'opencode2'] as const)( + 'preserves %s PowerShell call syntax and its run separator', + (agent) => { + for (const separator of ['', ' --']) { + const command = `& "C:\\Program Files\\opencode\\opencode.exe" --log-level debug run --standalone${separator}` + const plan = buildAgentStartupPlan({ + agent, + prompt: "--task's é", + cmdOverrides: { [agent]: command }, + platform: 'win32', + shell: 'powershell', + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe(`${command}${separator ? ' ' : ' -- '}'--task''s é'`) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts new file mode 100644 index 00000000000..e3b1c462ffa --- /dev/null +++ b/src/shared/opencode-startup-prompt.ts @@ -0,0 +1,39 @@ +import { isOpenCodeRunCommand } from './opencode-headless-command' +import { sha256 } from './sha256' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './tui-agent' + +export const OPENCODE_STARTUP_PROMPT_SHA256_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256' +export const OPENCODE_STARTUP_PROMPT_NONCE_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE' +export const OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT' +export const OPENCODE_STARTUP_PROMPT_CLAIM_PATH = '/opencode/startup-prompt/claim' +export const OPENCODE_STARTUP_PROMPT_BODY_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_BODY' +export const OPENCODE_STARTUP_PROMPT_SHELL_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL' + +export function openCodeStartupPromptEnv( + agent: TuiAgent, + command: string, + shell: AgentStartupShell, + prompt: string, + env: Record | null | undefined +): { env?: Record } { + const parsed = tokenizeStartupCommand(command, shell) + if ( + (agent !== 'opencode' && agent !== 'opencode2') || + !parsed.ok || + isOpenCodeRunCommand(parsed.tokens, shell) + ) { + return env ? { env: { ...env } } : {} + } + const digest = Array.from(sha256(new TextEncoder().encode(prompt)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') + return { + env: { + ...env, + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: digest, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: prompt, + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: shell + } + } +} diff --git a/src/shared/opencode-tui-config-registration.ts b/src/shared/opencode-tui-config-registration.ts index bcf7601c089..76c7f96e072 100644 --- a/src/shared/opencode-tui-config-registration.ts +++ b/src/shared/opencode-tui-config-registration.ts @@ -7,6 +7,8 @@ import { writeOverlayOpenCodePluginAtomically } from './opencode-plugin-atomic-write' +export class InvalidOpenCodeTuiConfigError extends Error {} + /** Register 1.x's explicit TUI entry without changing other settings or overlay targets. */ export function registerOpenCodeTuiPlugin( configDir: string, @@ -20,11 +22,13 @@ export function registerOpenCodeTuiPlugin( const errors: ParseError[] = [] const config: unknown = parse(text, errors, { allowTrailingComma: true }) if (errors.length || typeof config !== 'object' || !config || Array.isArray(config)) { - throw new Error('Cannot register OpenCode TUI plugin in invalid config') + throw new InvalidOpenCodeTuiConfigError('Cannot register OpenCode TUI plugin in invalid config') } const previous = 'plugin' in config ? config.plugin : undefined if (previous !== undefined && !Array.isArray(previous)) { - throw new Error('Cannot register OpenCode TUI plugin in invalid plugin list') + throw new InvalidOpenCodeTuiConfigError( + 'Cannot register OpenCode TUI plugin in invalid plugin list' + ) } const sourceDir = existsSync(configPath) ? dirname(realpathSync(configPath)) : configDir const entries: unknown[] = previous ?? [] diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index 24856776c54..54e06df8883 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -4,7 +4,10 @@ import { writeOverlayOpenCodePluginAtomically } from './opencode-plugin-atomic-write' import { dirname, join } from 'node:path' -import { registerOpenCodeTuiPlugin } from './opencode-tui-config-registration' +import { + InvalidOpenCodeTuiConfigError, + registerOpenCodeTuiPlugin +} from './opencode-tui-config-registration' import { isInstalledOpenCodePluginCurrent, isOverlayOpenCodePluginCurrent @@ -29,19 +32,33 @@ export function writeOpenCodeTuiPlugin( source: string, ownership: 'canonical' | 'overlay' = 'canonical' ): void { - const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) + writeOpenCodeTuiPluginDirectory( + pluginsDir, + openCodeTuiPluginDirName(pluginFileName), + source, + ownership + ) +} + +export function writeOpenCodeTuiPluginDirectory( + pluginsDir: string, + directoryName: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + const dir = join(pluginsDir, directoryName) const entry = join(dir, 'tui.js') // The 1.x TUI loader rejects a default object that also exposes server(). const tuiSource = source.includes('const ORCA_STATUS_AGENT = "opencode";') && source.includes('async function setupLegacyOpenCodeTui(') - ? `${source.replace(/^export default /m, 'const orcaServerPlugin = ')}\nconst { server: _orcaServerOnly, ...orcaTuiPlugin } = orcaServerPlugin;\nexport default { id: ${JSON.stringify(pluginFileName.replace(/\.js$/, ''))}, setup: setupOpenCode2Status, ...orcaTuiPlugin, tui: setupLegacyOpenCodeTui };\n` + ? `${source.replace(/^export default /m, 'const orcaServerPlugin = ')}\nconst { server: _orcaServerOnly, ...orcaTuiPlugin } = orcaServerPlugin;\nexport default { id: ${JSON.stringify(directoryName.replace(/-tui$/, ''))}, setup: setupOpenCode2Status, ...orcaTuiPlugin, tui: setupLegacyOpenCodeTui };\n` : source const isCurrent = ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent if (isCurrent(entry, tuiSource)) { if (tuiSource !== source) { - registerOpenCodeTuiPlugin(dirname(pluginsDir), entry, ownership) + registerTuiPlugin(pluginsDir, entry, ownership) } return } @@ -52,6 +69,22 @@ export function writeOpenCodeTuiPlugin( : writeOverlayOpenCodePluginAtomically write(entry, tuiSource) if (tuiSource !== source) { + registerTuiPlugin(pluginsDir, entry, ownership) + } +} + +function registerTuiPlugin( + pluginsDir: string, + entry: string, + ownership: 'canonical' | 'overlay' +): void { + try { registerOpenCodeTuiPlugin(dirname(pluginsDir), entry, ownership) + } catch (error) { + if (!(error instanceof InvalidOpenCodeTuiConfigError)) { + throw error + } + // Invalid TUI settings must not block the separate server status plugin. + console.warn('[OpenCode] Failed to register TUI status plugin:', entry, error) } } diff --git a/src/shared/orca-cli-shell-path.ts b/src/shared/orca-cli-shell-path.ts new file mode 100644 index 00000000000..6bbe825a45e --- /dev/null +++ b/src/shared/orca-cli-shell-path.ts @@ -0,0 +1,6 @@ +export const ORCA_CLI_POSIX_PATH_RESTORE = `if [ -n "\${ORCA_CLI_BIN_DIR:-}" ]; then + case "\${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR\${PATH:+:$PATH}" ;; + esac +fi` diff --git a/src/shared/pane-agent-identity-adapter.ts b/src/shared/pane-agent-identity-adapter.ts index c08e1b77dea..c367f9a0a70 100644 --- a/src/shared/pane-agent-identity-adapter.ts +++ b/src/shared/pane-agent-identity-adapter.ts @@ -7,6 +7,7 @@ import type { PaneAgentRunKey } from './pane-agent-identity-resolver' import type { PaneAgentEvidenceSource } from './pane-agent-evidence-sources' +import type { TerminalAgent } from './terminal-agent' import type { TuiAgent } from './tui-agent' /** @@ -31,7 +32,7 @@ export type PaneAgentCoverage = 'covered' | 'uncovered' * is unavailable or ambiguous, so that pane reads `uncovered` rather than guessed. */ export type ForegroundProcessProof = { - agent: TuiAgent + agent: TerminalAgent /** Opaque host-derived PID+start-time token. Compared for equality only, never decoded. */ processIncarnation: string ptyIncarnationId?: string @@ -72,12 +73,12 @@ export type PaneAgentIdentityEvidenceWire = { } export type CanonicalPaneAgentIdentityInput = { - hookAgent?: TuiAgent | null + hookAgent?: TerminalAgent | null hookIsLive?: boolean hookRun?: PaneAgentRunKey /** A distinct completed-hook signal for callers that hold live and completed rows separately * (the tab ladder does); `hookAgent` + `hookIsLive: false` remains the single-slot spelling. */ - completedHookAgent?: TuiAgent | null + completedHookAgent?: TerminalAgent | null completedHookRun?: PaneAgentRunKey launchAgent?: TuiAgent | null launchRun?: PaneAgentRunKey @@ -85,14 +86,14 @@ export type CanonicalPaneAgentIdentityInput = { * Foreground process NAME as currently read. Without a fresh `processProof` this is a weak * hint: it neither enters the covered process rung nor makes the pane covered. */ - foregroundAgent?: TuiAgent | null + foregroundAgent?: TerminalAgent | null processProof?: ForegroundProcessProof | null - sleepingSessionAgent?: TuiAgent | null + sleepingSessionAgent?: TerminalAgent | null sleepingRun?: PaneAgentRunKey /** Tab-level display fallback only; ignored unless `allowSibling` opts in. */ - siblingAgent?: TuiAgent | null + siblingAgent?: TerminalAgent | null /** Additional tab-level sibling observations retained for ambiguity checking. */ - siblingAgents?: readonly TuiAgent[] + siblingAgents?: readonly TerminalAgent[] allowSibling?: boolean title?: string | null currentRun?: PaneAgentRunKey @@ -102,11 +103,11 @@ export type CanonicalPaneAgentIdentityInput = { * uncovered lane is a temporary compatibility lane, not a new host-specific ranking; absent a * fallback, an uncovered pane answers from title evidence alone, marked title-only. */ - uncoveredFallback?: { agent: TuiAgent | null; titleOnly?: boolean } + uncoveredFallback?: { agent: TerminalAgent | null; titleOnly?: boolean } } export type CanonicalPaneAgentIdentity = { - agent: TuiAgent | null + agent: TerminalAgent | null source: PaneAgentEvidenceSource | null coverage: PaneAgentCoverage /** True when the answer was derived from a parsed title (the uncovered/title-only marking). */ @@ -144,7 +145,7 @@ function isPaneAgentRunEligible( } /** Shared evidence ranking primitive used by every pane-identity adapter. */ -export function resolveCanonicalPaneAgentEvidence( +export function resolveCanonicalPaneAgentEvidence( input: PaneAgentIdentityInput ): PaneAgentIdentity { const superseded: PaneAgentEvidenceSource[] = [] @@ -191,7 +192,7 @@ export function isForegroundProcessProofFresh(proof: ForegroundProcessProof): bo /** A proof only carries identity for the agent it names; a name mismatch is no proof at all. */ function processEvidenceFromProof( input: CanonicalPaneAgentIdentityInput -): PaneAgentEvidence | null { +): PaneAgentEvidence | null { const proof = input.processProof if (!proof || !isForegroundProcessProofFresh(proof)) { return null @@ -266,7 +267,7 @@ export function resolveCanonicalPaneAgentIdentity( ...(input.siblingAgents?.map((agent) => ({ source: 'sibling' as const, agent })) ?? []), ...(titleAgent ? [{ source: 'title' as const, agent: titleAgent }] : []) ] - const siblingResolved = resolveCanonicalPaneAgentEvidence({ + const siblingResolved = resolveCanonicalPaneAgentEvidence({ evidence: siblingEvidence, allowSibling: input.allowSibling, minimumSource: input.minimumSource @@ -281,7 +282,7 @@ export function resolveCanonicalPaneAgentIdentity( } } - const resolved = resolveCanonicalPaneAgentEvidence({ + const resolved = resolveCanonicalPaneAgentEvidence({ evidence: [ ...(input.hookAgent ? [ diff --git a/src/shared/pane-agent-identity-resolver.ts b/src/shared/pane-agent-identity-resolver.ts index 4b03c5de16d..33de6be0db9 100644 --- a/src/shared/pane-agent-identity-resolver.ts +++ b/src/shared/pane-agent-identity-resolver.ts @@ -1,6 +1,6 @@ import { resolveCanonicalPaneAgentEvidence } from './pane-agent-identity-adapter' import type { PaneAgentEvidenceSource } from './pane-agent-evidence-sources' -import type { TuiAgent } from './tui-agent' +import type { TerminalAgent } from './terminal-agent' export { PANE_AGENT_EVIDENCE_SOURCES } from './pane-agent-evidence-sources' export type { PaneAgentEvidenceSource } from './pane-agent-evidence-sources' @@ -44,7 +44,7 @@ export type PaneAgentRunKey = { incarnation: number } -export type PaneAgentEvidence = { +export type PaneAgentEvidence = { source: PaneAgentEvidenceSource agent: A /** @@ -55,7 +55,7 @@ export type PaneAgentEvidence = { run?: PaneAgentRunKey } -export type PaneAgentIdentityInput = { +export type PaneAgentIdentityInput = { evidence: readonly PaneAgentEvidence[] /** The pane's current run. Undefined disables run filtering entirely (old peer, mixed version). */ currentRun?: PaneAgentRunKey @@ -72,7 +72,7 @@ export type PaneAgentIdentityInput = { allowSibling?: boolean } -export type PaneAgentIdentity = { +export type PaneAgentIdentity = { agent: A | null /** Which class of evidence decided it. Null when nothing eligible remained. */ source: PaneAgentEvidenceSource | null @@ -86,14 +86,14 @@ export type PaneAgentIdentity = { * Resolves one pane's agent from ranked evidence. * * Generic over the agent vocabulary: the sidebar speaks the widened `AgentType` and the tab speaks - * the strict `TuiAgent`. Ranking evidence does not depend on which, and a cast at that boundary + * the strict `TerminalAgent`. Ranking evidence does not depend on which, and a cast at that boundary * would only hide the mismatch. * * Returns null rather than guessing. A pane with no eligible evidence shows no agent, which is * recoverable; showing the wrong agent is not, and at the action surfaces (orchestration routing, * mailbox delivery, prompt-cache timers) it is a misdelivery rather than a cosmetic slip. */ -export function resolvePaneAgentIdentity( +export function resolvePaneAgentIdentity( input: PaneAgentIdentityInput ): PaneAgentIdentity { return resolveCanonicalPaneAgentEvidence(input) diff --git a/src/shared/pane-agent-identity-title-corpus.test.ts b/src/shared/pane-agent-identity-title-corpus.test.ts index f24cd431065..3b81bd8e7a9 100644 --- a/src/shared/pane-agent-identity-title-corpus.test.ts +++ b/src/shared/pane-agent-identity-title-corpus.test.ts @@ -5,7 +5,7 @@ import { createHash, randomBytes } from 'node:crypto' import { describe, expect, it } from 'vitest' import { collectAgentTitleEvidence } from './agent-title-evidence' import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter' -import type { TuiAgent } from './tui-agent' +import type { TerminalAgent } from './terminal-agent' /** * Title regression gates for the identity-ladder migration. @@ -64,12 +64,12 @@ function loadRecordedTitleCorpus(): { checkpointCount: number; titles: string[] } /** What the canonical adapter answers when a title is all a pane has (the uncovered lane). */ -function canonicalTitleOnlyAgent(title: string): TuiAgent | null { +function canonicalTitleOnlyAgent(title: string): TerminalAgent | null { return resolveCanonicalPaneAgentIdentity({ title }).agent } describe('controlled title fixtures (always run)', () => { - const FIXTURES: readonly { name: string; title: string; expected: TuiAgent | null }[] = [ + const FIXTURES: readonly { name: string; title: string; expected: TerminalAgent | null }[] = [ { name: 'mandatory adversarial owner suffix beats the agent names in task text', title: 'STA-4011 Linux Antigravity Commit Messages - grok', diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index feb9a29d213..6eaa295eed4 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -172,6 +172,8 @@ export type PersistedUIState = { usageEmptyStateDismissed?: boolean /** One-shot toast announcing per-terminal Codex servers; set when shown, so absent means not yet seen. */ codexTerminalServerIsolationNoticeSeen?: boolean + /** Windows one-shot toast for Codex moving onto ~/.codex; set when shown, so absent means not yet seen. */ + codexSharedSettingsNoticeSeen?: boolean /** URL for new browser tabs; null = blank tab. */ browserDefaultUrl?: string | null browserDefaultSearchEngine?: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 47deda80d81..0ab7a210580 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -196,6 +196,20 @@ export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = // 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: a host advertising this answers a resent send id from its record before anything else may +// refuse it, so a refusal `agentSession.send` RETURNS is proof; a thrown error never is, a thrown +// refusal included (host not installed, journal database won't open, host disabled). Reading a +// returned `ok: false`: `agent_session_operation_unknown` with `outcomeUnknown` or `resultLost` — +// the host cannot tell yet, resend the same id; with `rewindUnconfirmed` — settled, nothing was +// written. `agent_session_operation_expired` — only the transcript can tell. An +// `agent_session_operation_conflict` or `messageIdReused` — the id holds a different payload, +// which proves nothing about this message; nor does `sessionNotAttached` (the chat's record is +// gone or unreadable on this host). Any other — the chat holds no message under that id and none +// is in flight, but a resend of that id may still run as a new send, so a client that hands the +// text back must not resend the old id. An older host may refuse an id it recorded: none of this +// holds there. +export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = + 'agent-session.send-answers-proof.v1' as const // Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a // capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON // PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer @@ -400,6 +414,7 @@ export const RUNTIME_CAPABILITIES = [ // The host side: it accepts a send before any agent has it, and a Stop with no writer before a // turn starts, so a client may gate on either. AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, diff --git a/src/shared/published-pane-agent-identity.test.ts b/src/shared/published-pane-agent-identity.test.ts index 6a338e891c6..0e299c98ff6 100644 --- a/src/shared/published-pane-agent-identity.test.ts +++ b/src/shared/published-pane-agent-identity.test.ts @@ -4,6 +4,13 @@ import { resolvePublishedPaneAgentIdentity } from './published-pane-agent-identi const resolve = resolvePublishedPaneAgentIdentity describe('resolvePublishedPaneAgentIdentity', () => { + it('publishes a manually started DeepSeek Build owner without readable process evidence', () => { + expect(resolve({ title: '⠋ - Review Codex integration - DeepSeek Build' })).toBe('dsb') + expect(resolve({ title: 'DeepSeek Build' })).toBe('dsb') + expect(resolve({ title: 'Terminal', foregroundAgent: 'dsb' })).toBe('dsb') + expect(resolve({ title: 'Review DeepSeek Build integration' })).toBeUndefined() + }) + describe('a task title cannot name the pane', () => { // Minimized from real recorded titles. Each is a pane of one agent whose task text names // another; before this, `@` routing delivered to them. diff --git a/src/shared/published-pane-agent-identity.ts b/src/shared/published-pane-agent-identity.ts index a00864a3b3a..d2714b26d54 100644 --- a/src/shared/published-pane-agent-identity.ts +++ b/src/shared/published-pane-agent-identity.ts @@ -1,5 +1,6 @@ import { collectAgentTitleEvidence } from './agent-title-evidence' import { resolvePaneAgentIdentity } from './pane-agent-identity-resolver' +import type { TerminalAgent } from './terminal-agent' import type { TuiAgent } from './tui-agent' /** @@ -31,13 +32,13 @@ export function resolvePublishedPaneAgentIdentity(args: { * only one that survives WSL, where the Windows host reads the foreground process as `wsl.exe` * rather than the agent running inside the distro. */ - hookAgent?: TuiAgent | null + hookAgent?: TerminalAgent | null /** Whether that hook belongs to a turn in progress, as opposed to one that finished. */ hookIsLive?: boolean launchAgent?: TuiAgent | null - foregroundAgent?: TuiAgent | null + foregroundAgent?: TerminalAgent | null title?: string | null -}): TuiAgent | undefined { +}): TerminalAgent | undefined { const titleAgent = args.title ? collectAgentTitleEvidence(args.title).agent : null return ( resolvePaneAgentIdentity({ diff --git a/src/shared/quick-open-filter.test.ts b/src/shared/quick-open-filter.test.ts index c481a1e8854..e6b65c8d2bb 100644 --- a/src/shared/quick-open-filter.test.ts +++ b/src/shared/quick-open-filter.test.ts @@ -164,6 +164,19 @@ describe('buildHiddenDirExcludeGlobs', () => { }) describe('buildRgArgsForQuickOpen', () => { + it.each([ + { searchRoot: '.', excludePathPrefixes: [], forceSlashSeparator: false }, + { + searchRoot: '/root', + excludePathPrefixes: ['packages/app', 'feature[1]'], + forceSlashSeparator: true + } + ])('broadens only VCS ignore handling for $searchRoot', (options) => { + const { primary, ignoredPass } = buildRgArgsForQuickOpen(options) + expect(ignoredPass).toContain('--no-ignore-vcs') + expect(ignoredPass.filter((arg) => arg !== '--no-ignore-vcs')).toEqual(primary) + }) + it('primary pass includes --files, --hidden, hidden-dir excludes, no --follow', () => { const { primary } = buildRgArgsForQuickOpen({ searchRoot: '/root', @@ -212,6 +225,18 @@ describe('buildRgArgsForQuickOpen', () => { }) }) +it('pins both Quick Open passes to config-independent NUL output', () => { + const { primary, ignoredPass } = buildRgArgsForQuickOpen({ + searchRoot: '.', + excludePathPrefixes: [], + forceSlashSeparator: true + }) + for (const args of [primary, ignoredPass]) { + expect(args).toContain('--no-config') + expect(args).toContain('--null') + } +}) + describe('normalizeQuickOpenRgLine', () => { it('strips absolute root prefix', () => { expect( @@ -253,9 +278,9 @@ describe('normalizeQuickOpenRgLine', () => { expect(normalizeQuickOpenRgLine('..', { kind: 'cwd-relative' })).toBeNull() }) - it('strips CRLF', () => { + it('preserves a trailing carriage return in a NUL-delimited filename', () => { expect(normalizeQuickOpenRgLine('/root/a.ts\r', { kind: 'absolute', rootPath: '/root' })).toBe( - 'a.ts' + 'a.ts\r' ) }) diff --git a/src/shared/quick-open-filter.ts b/src/shared/quick-open-filter.ts index b7592657a11..7c4da971a01 100644 --- a/src/shared/quick-open-filter.ts +++ b/src/shared/quick-open-filter.ts @@ -6,7 +6,7 @@ * Centralized to stop local/relay listFiles from drifting on blocklist, ignores, exclusions, * timeouts, and buffering. See docs/design/share-quick-open-file-listing.md. */ -import { relativePathInsideRoot } from './cross-platform-path' +import { isWindowsAbsolutePathLike, relativePathInsideRoot } from './cross-platform-path' // ─── Hidden-dir blocklist ──────────────────────────────────────────── @@ -93,7 +93,7 @@ export function buildExcludePathPrefixes(rootPath: string, excludePaths?: unknow if (relativePath === null) { continue } - let rel = relativePath.replace(/\\/g, '/') + let rel = isWindowsAbsolutePathLike(rootPath) ? relativePath.replace(/\\/g, '/') : relativePath if (!rel || isParentRelativePath(rel) || rel.startsWith('/')) { continue } @@ -183,7 +183,7 @@ export type RgArgsOptions = { export type RgArgs = { /** Main pass: all non-ignored files, hidden dotfiles included. */ primary: string[] - /** Second pass: ignored files, hidden dotfiles included. */ + /** Broader pass: primary files plus gitignored files, hidden dotfiles included. */ ignoredPass: string[] } @@ -204,6 +204,8 @@ export function buildRgArgsForQuickOpen(opts: RgArgsOptions): RgArgs { const primary = [ '--files', + '--no-config', + '--null', '--hidden', ...sepArgs, ...hiddenDirGlobs, @@ -214,6 +216,8 @@ export function buildRgArgsForQuickOpen(opts: RgArgsOptions): RgArgs { // Ignored pass: --no-ignore-vcs broadens to gitignored/parent/global ignored files; blocklist globs still guard. const ignoredPass = [ '--files', + '--no-config', + '--null', '--hidden', '--no-ignore-vcs', ...sepArgs, @@ -234,20 +238,18 @@ export type RgOutputMode = | { kind: 'cwd-relative' } /** - * Convert one rg --files stdout line into a root-relative, `/`-separated path. - * Returns `null` for lines that escape the root (symlink edge cases) or can't be normalized. + * Convert one NUL-delimited rg --files record into a root-relative, `/`-separated path. + * Returns `null` for records that escape the root (symlink edge cases) or can't be normalized. * Callers do any WSL translation first, keeping WSL out of the shared module. */ export function normalizeQuickOpenRgLine(rawLine: string, outputMode: RgOutputMode): string | null { - let line = rawLine - // Strip CR so CRLF from rg on Windows doesn't leak into results. - if (line.length > 0 && line.charCodeAt(line.length - 1) === 13) { - line = line.substring(0, line.length - 1) - } + const line = rawLine if (!line) { return null } - const normalized = line.replace(/\\/g, '/') + const windowsPath = + outputMode.kind === 'absolute' && isWindowsAbsolutePathLike(outputMode.rootPath) + const normalized = windowsPath ? line.replace(/\\/g, '/') : line if (outputMode.kind === 'cwd-relative') { let rel = normalized if (rel.startsWith('./')) { @@ -262,7 +264,8 @@ export function normalizeQuickOpenRgLine(rawLine: string, outputMode: RgOutputMo } // Absolute mode: strip the root prefix. // Why: only replace backslashes; collapsing repeated slashes would break Windows UNC roots (`\\server\share`). - const normalizedRoot = `${outputMode.rootPath.replace(/\\/g, '/').replace(/\/+$/, '')}/` + const root = windowsPath ? outputMode.rootPath.replace(/\\/g, '/') : outputMode.rootPath + const normalizedRoot = `${root.replace(/\/+$/, '')}/` if (normalized.startsWith(normalizedRoot)) { const rel = normalized.substring(normalizedRoot.length) if (!rel || isParentRelativePath(rel) || rel.startsWith('/')) { diff --git a/src/shared/quick-open-ripgrep-output-mode.ts b/src/shared/quick-open-ripgrep-output-mode.ts new file mode 100644 index 00000000000..e3a8f1541a1 --- /dev/null +++ b/src/shared/quick-open-ripgrep-output-mode.ts @@ -0,0 +1,14 @@ +import type { RgOutputMode } from './quick-open-filter' + +export function getQuickOpenRgOutputMode( + rawLine: string, + translatedLine: string, + rootPath: string +): RgOutputMode { + return translatedLine !== rawLine || + rawLine.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(rawLine) || + rawLine.startsWith('\\\\') + ? { kind: 'absolute', rootPath } + : { kind: 'cwd-relative' } +} diff --git a/src/shared/ripgrep-dense-match-json.test.ts b/src/shared/ripgrep-dense-match-json.test.ts new file mode 100644 index 00000000000..ec62caddb2f --- /dev/null +++ b/src/shared/ripgrep-dense-match-json.test.ts @@ -0,0 +1,93 @@ +import { expect, it } from 'vitest' +import { parseDenseRipgrepMatchJson } from './ripgrep-dense-match-json' + +it.each(['', '\\', '\n', '\n'.repeat(64 * 1024), `${'x'.repeat(64 * 1024)}\n`])( + 'preserves U+FEFF in text and filenames across string-buffer boundaries (%#)', + (prefix) => { + const text = `${prefix}\ufeff😀x` + const source = { + type: 'match', + data: { + path: { text }, + lines: { text }, + line_number: 1, + submatches: [{ start: 0, end: 1 }] + } + } + const record = JSON.stringify(source) + for (const encoded of [record, record.replaceAll('\ufeff', '\\uFEFF')]) { + expect(parseDenseRipgrepMatchJson(encoded, 1, 16)).toEqual(JSON.parse(record)) + } + } +) + +it('retains only exact match fields and the remaining range budget', () => { + const ranges = [ + { start: 0, end: 1 }, + { start: 2, end: 3 } + ] + const source = { + type: 'match', + submatches: [{ start: 99, end: 100 }], + data: { + nested: { submatches: [{ start: 88, end: 89 }] }, + lines: { text: 'a "submatches" b', bytes: 'YQ==' }, + path: { text: '/root/a.ts' }, + line_number: 12, + submatches: ranges + } + } + expect(parseDenseRipgrepMatchJson(JSON.stringify(source), 1, 16)).toEqual({ + type: 'match', + data: { + lines: source.data.lines, + path: source.data.path, + line_number: 12, + submatches: ranges.slice(0, 1) + } + }) +}) + +it('rejects depth overflow and invalid submatch shapes', () => { + expect(() => parseDenseRipgrepMatchJson('['.repeat(17), 2, 16)).toThrow() + expect(() => parseDenseRipgrepMatchJson('{"data":{"submatches":[null]}}', 2, 16)).toThrow() +}) + +it.each(['{}', '{"type":"begin","data":{}}', '{"data":null}', '{"data":[]}'])( + 'does not fabricate a match from %s', + (source) => { + const projected = parseDenseRipgrepMatchJson(source, 2, 16) + expect(projected.data?.path).toBeUndefined() + expect(projected.data?.submatches).toEqual([]) + } +) + +it('validates submatches after reaching the requested result cap', () => { + const source = JSON.stringify({ + type: 'match', + data: { + submatches: [ + { start: 0, end: 1 }, + { start: null, end: 2 } + ] + } + }) + expect(() => parseDenseRipgrepMatchJson(source, 1, 16)).toThrow('Invalid rg submatch') +}) + +it('rejects duplicate coordinate fields whose final value is not numeric', () => { + const source = '{"data":{"submatches":[{"start":0,"start":{},"end":1}]}}' + expect(() => parseDenseRipgrepMatchJson(source, 1, 16)).toThrow('Invalid rg submatch') +}) + +it.each([16_378, 16_379])('retains the existing per-element token budget at %i values', (count) => { + const source = JSON.stringify({ + data: { submatches: [{ start: 0, end: 1, other: Array(count).fill(0) }] } + }) + const parse = (): unknown => parseDenseRipgrepMatchJson(source, 1, 16) + if (count === 16_378) { + expect(parse()).toMatchObject({ data: { submatches: [{ start: 0, end: 1 }] } }) + } else { + expect(parse).toThrow('rg submatch structure exceeds limit') + } +}) diff --git a/src/shared/ripgrep-dense-match-json.ts b/src/shared/ripgrep-dense-match-json.ts new file mode 100644 index 00000000000..5b54069fb10 --- /dev/null +++ b/src/shared/ripgrep-dense-match-json.ts @@ -0,0 +1,165 @@ +import { + assertJsonTextStructureWithinLimits, + JsonTextStructureCapacityError, + type JsonTextStructureLimits +} from './json-text-structure-limit' +import { createJsonTokenReader } from './json-token-reader' + +export type RipgrepMatchMessage = { + type?: string + data?: { + path?: { text?: string } + lines?: { text?: string; bytes?: string } + line_number?: number + submatches?: { start: number; end: number }[] + } +} + +export function parseRipgrepMatchJson( + line: string, + maxMatches: number, + limits: JsonTextStructureLimits +): RipgrepMatchMessage { + try { + assertJsonTextStructureWithinLimits(line, limits) + return JSON.parse(line) + } catch (error) { + if ( + !(error instanceof JsonTextStructureCapacityError) || + error.resource !== 'structuralTokens' + ) { + throw error + } + return parseDenseRipgrepMatchJson(line, maxMatches, limits.nestingDepth) + } +} + +type RipgrepJsonFrame = { + kind: 'object' | 'array' + context: 'root' | 'data' | 'path' | 'lines' | 'matches' | 'match' | null + key?: string + values: number + keys?: Set + start?: number + end?: number +} + +/** Dense rg records retain only the requested ranges while validating the entire record. */ +export function parseDenseRipgrepMatchJson( + line: string, + maxMatches: number, + nestingDepth: number +): RipgrepMatchMessage { + const data: NonNullable = { submatches: [] } + const result: RipgrepMatchMessage = { data } + const frames: RipgrepJsonFrame[] = [] + let elementTokens = 0 + const countTokens = (amount = 1): void => { + if (frames.length >= 4 && (elementTokens += amount) > 32 * 1024) { + throw new Error('rg submatch structure exceeds limit') + } + } + const parser = createJsonTokenReader((token) => { + const current = frames.at(-1) + if (token.name === 'keyValue') { + if (!current || current.kind !== 'object') { + throw new SyntaxError('Unexpected rg object key') + } + if (current.values++ > 0) { + countTokens() + } + // Packed tokens omit commas and colons; include them in the existing structure budget. + countTokens(2) + current.key = token.value + if (current.keys?.has(token.value)) { + throw new SyntaxError('Duplicate rg object key') + } + current.keys?.add(token.value) + if ((current.keys?.size ?? 0) > 128) { + throw new Error('Too many rg record fields') + } + return + } + if (token.name === 'endObject' || token.name === 'endArray') { + const frame = frames.pop() + countTokens() + if (frame?.context === 'match') { + if (typeof frame.start !== 'number' || typeof frame.end !== 'number') { + throw new SyntaxError('Invalid rg submatch') + } + if (data.submatches && data.submatches.length < maxMatches) { + data.submatches.push({ start: frame.start, end: frame.end }) + } + } + return + } + if (current?.kind === 'array' && current.values++ > 0) { + countTokens() + } + if (current?.context === 'match' && (current.key === 'start' || current.key === 'end')) { + current[current.key] = undefined + } + if (token.name === 'startObject' || token.name === 'startArray') { + const kind = token.name === 'startObject' ? 'object' : 'array' + let context: RipgrepJsonFrame['context'] = null + if (!current && kind === 'object') { + context = 'root' + } else if (current?.context === 'root' && current.key === 'data' && kind === 'object') { + context = 'data' + } else if (current?.context === 'data') { + if ((current.key === 'lines' || current.key === 'path') && kind === 'object') { + context = current.key + } + if (current.key === 'submatches' && kind === 'array') { + context = 'matches' + } + } else if (current?.context === 'matches') { + if (kind !== 'object') { + throw new SyntaxError('Invalid rg submatch') + } + context = 'match' + } + frames.push({ + kind, + context, + values: 0, + keys: kind === 'object' && frames.length < 2 ? new Set() : undefined + }) + if (frames.length > nestingDepth) { + throw new Error('rg record nesting exceeds limit') + } + if (frames.length === 4) { + elementTokens = 0 + } + countTokens() + return + } + countTokens() + if (current?.context === 'matches') { + throw new SyntaxError('Invalid rg submatch') + } + if (token.name === 'numberValue') { + const value = Number(token.value) + if (current?.context === 'match' && (current.key === 'start' || current.key === 'end')) { + current[current.key] = value + } + if (current?.context === 'data' && current.key === 'line_number') { + data.line_number = value + } + } else if (token.name === 'stringValue') { + if (current?.context === 'root' && current.key === 'type') { + result.type = token.value + } + if (current?.context === 'path' && current.key === 'text') { + data.path = { text: token.value } + } + if (current?.context === 'lines' && (current.key === 'text' || current.key === 'bytes')) { + data.lines ??= {} + data.lines[current.key] = token.value + } + } + }, 8 * 1024) + parser.write(line) + parser.end() + return result +} diff --git a/src/shared/ripgrep-filename-decoder.test.ts b/src/shared/ripgrep-filename-decoder.test.ts new file mode 100644 index 00000000000..57af67e1abe --- /dev/null +++ b/src/shared/ripgrep-filename-decoder.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { RipgrepFilenameDecoder, RipgrepFilenameEncodingError } from './ripgrep-filename-decoder' + +describe('ripgrep filename decoding', () => { + it('preserves split scalars, BOMs and literal replacement characters', () => { + const decoder = new RipgrepFilenameDecoder() + const text = '\uFEFF日本語😀\uFFFD\0' + let decoded = '' + for (const byte of Buffer.from(text)) { + decoded += decoder.decode(Buffer.from([byte])) + } + decoder.finish() + expect(decoded).toBe(text) + }) + + it.each([[0xff], [0x80], [0xc0, 0xaf], [0xed, 0xa0, 0x80]])( + 'refuses invalid filename bytes %j', + (...bytes) => { + expect(() => new RipgrepFilenameDecoder().decode(Buffer.from(bytes))).toThrow( + RipgrepFilenameEncodingError + ) + } + ) + + it('rejects an incomplete scalar at EOF', () => { + const decoder = new RipgrepFilenameDecoder() + expect(decoder.decode(Buffer.from([0xf0, 0x9f]))).toBe('') + expect(() => decoder.finish()).toThrow('not valid UTF-8') + }) + + it('accepts string fixtures without losing decoder state', () => { + const decoder = new RipgrepFilenameDecoder() + expect(decoder.decode('literal-�\0')).toBe('literal-�\0') + decoder.finish() + }) +}) + +it('refuses literal WSL backslashes only when Windows translation is required', () => { + const errors: Error[] = [] + const decoder = new RipgrepFilenameDecoder((error) => errors.push(error), true) + expect(decoder.decode(Buffer.from('./a\\b.txt\0'))).toBeNull() + expect(errors[0]?.message).toContain('WSL filenames containing a backslash') + expect(new RipgrepFilenameDecoder().decode(Buffer.from('./a\\b.txt\0'))).toBe('./a\\b.txt\0') +}) diff --git a/src/shared/ripgrep-filename-decoder.ts b/src/shared/ripgrep-filename-decoder.ts new file mode 100644 index 00000000000..7f903b3832d --- /dev/null +++ b/src/shared/ripgrep-filename-decoder.ts @@ -0,0 +1,51 @@ +export class RipgrepFilenameError extends Error {} + +export class RipgrepFilenameEncodingError extends RipgrepFilenameError { + constructor() { + super('File listing contains a filename that is not valid UTF-8') + this.name = 'RipgrepFilenameEncodingError' + } +} + +/** Replacement decoding would return a different, potentially existing filename. */ +export class RipgrepFilenameDecoder { + private readonly decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }) + + constructor( + private readonly onError: (error: Error) => void = throwFilenameError, + private readonly rejectBackslash = false + ) {} + + decode(chunk: Buffer | string): string | null { + try { + const decoded = this.decoder.decode(typeof chunk === 'string' ? Buffer.from(chunk) : chunk, { + stream: true + }) + if (this.rejectBackslash && decoded.includes('\\')) { + throw new RipgrepFilenameError( + 'WSL filenames containing a backslash cannot be opened through Windows paths' + ) + } + return decoded + } catch (error) { + this.onError( + error instanceof RipgrepFilenameError ? error : new RipgrepFilenameEncodingError() + ) + return null + } + } + + finish(): boolean { + try { + this.decoder.decode() + return true + } catch { + this.onError(new RipgrepFilenameEncodingError()) + return false + } + } +} + +function throwFilenameError(error: Error): never { + throw error +} diff --git a/src/shared/ripgrep-line-decoding.test.ts b/src/shared/ripgrep-line-decoding.test.ts new file mode 100644 index 00000000000..3c28d76a37c --- /dev/null +++ b/src/shared/ripgrep-line-decoding.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { decodeRipgrepLine } from './ripgrep-line-decoding' +import { ripgrepMatchRanges } from './ripgrep-match-offsets' + +describe('ripgrep byte line decoding', () => { + it.each([ + [0xff], + [0xc0, 0xaf], + [0xe2, 0x82], + [0xf0, 0x90, 0x80], + [0xed, 0xa0, 0x80], + [0xf4, 0x90, 0x80, 0x80], + [0x80, 0xbf], + [0xe0, 0x80, 0xaf] + ])('maps text after malformed sequence %j like Node UTF8 decoding', (...prefix) => { + const bytes = Buffer.concat([ + Buffer.from('😀é'), + Buffer.from(prefix), + Buffer.from('needle\r\n') + ]) + const decoded = decodeRipgrepLine({ bytes: bytes.toString('base64') }) + expect(decoded.text).toBe(bytes.toString('utf8').replace(/\n$/, '')) + expect(decoded.readOffset(bytes.length - 8)).toBe(decoded.text.indexOf('needle')) + expect(decoded.readOffset(bytes.length - 2)).toBe(decoded.text.indexOf('needle') + 6) + }) + + it('rejects partial replacement boundaries and overlapping ranges without inventing coordinates', () => { + const decoded = decodeRipgrepLine({ + bytes: Buffer.from([0xe2, 0x82, 0x20, 0x78]).toString('base64') + }) + let invalid = 0 + const ranges = [ + ...ripgrepMatchRanges( + decoded.text, + [ + { start: 0, end: 1 }, + { start: 2, end: 3 }, + { start: 2, end: 4 }, + { start: 4, end: 4 } + ], + decoded.readOffset, + () => invalid++ + ) + ] + expect(ranges).toEqual([ + { start: 1, end: 2 }, + { start: 3, end: 3 } + ]) + expect(invalid).toBe(2) + }) + + it('agrees with Node on all two-byte prefixes followed by ASCII', () => { + for (let first = 0; first < 256; first++) { + for (let second = 0; second < 256; second++) { + const bytes = Buffer.from([first, second, 0x78]) + const decoded = decodeRipgrepLine({ bytes: bytes.toString('base64') }) + expect(decoded.readOffset(2)).toBe(bytes.subarray(0, 2).toString('utf8').length) + } + } + }) +}) diff --git a/src/shared/ripgrep-line-decoding.ts b/src/shared/ripgrep-line-decoding.ts new file mode 100644 index 00000000000..c94091855f4 --- /dev/null +++ b/src/shared/ripgrep-line-decoding.ts @@ -0,0 +1,60 @@ +import { createRipgrepOffsetReader } from './ripgrep-match-offsets' + +function utf8SequenceWidth(bytes: Buffer, start: number): number { + const lead = bytes[start]! + const expected = + lead >= 0xc2 && lead <= 0xdf + ? 2 + : lead >= 0xe0 && lead <= 0xef + ? 3 + : lead >= 0xf0 && lead <= 0xf4 + ? 4 + : 1 + for (let offset = 1; offset < expected; offset++) { + const next = bytes[start + offset] + if ( + next === undefined || + next < 0x80 || + next > 0xbf || + (offset === 1 && + ((lead === 0xe0 && next < 0xa0) || + (lead === 0xed && next >= 0xa0) || + (lead === 0xf0 && next < 0x90) || + (lead === 0xf4 && next >= 0x90))) + ) { + return offset + } + } + return expected +} + +function createDecodedByteOffsetReader(bytes: Buffer): (offset: number) => number | null { + let position = 0 + let column = 0 + return (offset) => { + if (!Number.isSafeInteger(offset) || offset < position) { + return null + } + while (position < offset && position < bytes.length) { + const width = utf8SequenceWidth(bytes, position) + position += width + column += width === 4 ? 2 : 1 + } + return offset === position ? column : null + } +} + +/** Decode once; malformed sequences retain their original byte widths for match coordinates. */ +export function decodeRipgrepLine(data: { text?: string; bytes?: string } | undefined): { + text: string + readOffset: (offset: number) => number | null +} { + if (typeof data?.text === 'string') { + return { text: data.text.replace(/\n$/, ''), readOffset: createRipgrepOffsetReader(data.text) } + } + const bytes = Buffer.from(data?.bytes ?? '', 'base64') + return { + text: bytes.toString('utf8').replace(/\n$/, ''), + readOffset: createDecodedByteOffsetReader(bytes) + } +} diff --git a/src/shared/ripgrep-match-offsets.test.ts b/src/shared/ripgrep-match-offsets.test.ts new file mode 100644 index 00000000000..198af0b9ab8 --- /dev/null +++ b/src/shared/ripgrep-match-offsets.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { createRipgrepOffsetReader, ripgrepMatchRanges } from './ripgrep-match-offsets' + +describe('ripgrep match offsets', () => { + it('converts ordered ASCII, accented, CJK and astral boundaries to UTF16', () => { + const text = 'aé日😀z' + const read = createRipgrepOffsetReader(text) + expect([0, 1, 3, 6, 10, 11].map(read)).toEqual([0, 1, 2, 3, 5, 6]) + expect(read(11)).toBe(6) + }) + + it('rejects invalid, backwards and partial codepoint offsets', () => { + const read = createRipgrepOffsetReader('é😀') + expect(read(-1)).toBeNull() + expect(read(0.5)).toBeNull() + expect(read(1)).toBeNull() + expect(read(2)).toBe(1) + expect(read(0)).toBeNull() + expect(read(6)).toBe(3) + expect(read(7)).toBeNull() + }) + + it('handles many adjacent matches in one pass', () => { + const read = createRipgrepOffsetReader('😀x'.repeat(10_000)) + for (let index = 0; index < 10_000; index++) { + expect(read(index * 5)).toBe(index * 3) + expect(read(index * 5 + 4)).toBe(index * 3 + 2) + } + }) + + it('preserves zero-length matches and whole-codepoint line fallbacks', () => { + expect([...ripgrepMatchRanges('😀é', [{ start: 4, end: 4 }])]).toEqual([{ start: 2, end: 2 }]) + expect([...ripgrepMatchRanges('😀é', [])]).toEqual([{ start: 0, end: 2 }]) + expect([...ripgrepMatchRanges('', [])]).toEqual([{ start: 0, end: 0 }]) + }) +}) diff --git a/src/shared/ripgrep-match-offsets.ts b/src/shared/ripgrep-match-offsets.ts new file mode 100644 index 00000000000..fe667349701 --- /dev/null +++ b/src/shared/ripgrep-match-offsets.ts @@ -0,0 +1,37 @@ +/** Converts ordered ripgrep byte offsets without rescanning each match's prefix. */ +export function createRipgrepOffsetReader(text: string): (byteOffset: number) => number | null { + let bytePosition = 0 + let column = 0 + return (byteOffset) => { + if (!Number.isSafeInteger(byteOffset) || byteOffset < bytePosition) { + return null + } + while (bytePosition < byteOffset && column < text.length) { + const point = text.codePointAt(column)! + bytePosition += point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4 + column += point > 0xffff ? 2 : 1 + } + return bytePosition === byteOffset ? column : null + } +} + +export function* ripgrepMatchRanges( + text: string, + submatches: readonly { start: number; end: number }[], + readOffset = createRipgrepOffsetReader(text), + onInvalidRange?: () => void +): Generator<{ start: number; end: number }> { + if (submatches.length === 0) { + yield { start: 0, end: text.length > 0 ? (text.codePointAt(0)! > 0xffff ? 2 : 1) : 0 } + return + } + for (const submatch of submatches) { + const start = readOffset(submatch.start) + const end = readOffset(submatch.end) + if (start !== null && end !== null) { + yield { start, end } + } else { + onInvalidRange?.() + } + } +} diff --git a/src/shared/ripgrep-search-diagnostics.test.ts b/src/shared/ripgrep-search-diagnostics.test.ts new file mode 100644 index 00000000000..3bacf4678b8 --- /dev/null +++ b/src/shared/ripgrep-search-diagnostics.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { createAccumulator } from './text-search' +import { RipgrepSearchDiagnostics } from './ripgrep-search-diagnostics' + +describe('ripgrep search diagnostics', () => { + it('reports invalid regex diagnostics instead of an empty successful search', () => { + const diagnostics = new RipgrepSearchDiagnostics() + diagnostics.append(Buffer.from('regex parse error: unclosed character class')) + expect(diagnostics.failure(2, null, createAccumulator())?.message).toContain( + 'unclosed character class' + ) + }) + + it('limits retained diagnostics even for large stderr chunks', () => { + const diagnostics = new RipgrepSearchDiagnostics() + diagnostics.append('x'.repeat(100_000)) + diagnostics.append('must not be retained') + const error = diagnostics.failure(2, null, createAccumulator()) + expect(error?.message).toBe(`Search failed (2): ${'x'.repeat(4096)}`) + }) + + it('marks permission-error results as incomplete', () => { + const acc = createAccumulator() + acc.totalMatches = 1 + expect(new RipgrepSearchDiagnostics().failure(2, null, acc)).toBeNull() + expect(acc.truncated).toBe(true) + }) + + it('distinguishes a killed search from intentional truncation', () => { + const diagnostics = new RipgrepSearchDiagnostics() + const acc = createAccumulator() + expect(diagnostics.failure(null, 'SIGTERM', acc)).toBeInstanceOf(Error) + acc.truncated = true + expect(diagnostics.failure(null, 'SIGTERM', acc)).toBeNull() + }) + + it.each([0, 1])('accepts normal exit %i without inventing truncation', (code) => { + const acc = createAccumulator() + expect(new RipgrepSearchDiagnostics().failure(code, null, acc)).toBeNull() + expect(acc.truncated).toBe(false) + }) +}) diff --git a/src/shared/ripgrep-search-diagnostics.ts b/src/shared/ripgrep-search-diagnostics.ts new file mode 100644 index 00000000000..4b5445a79eb --- /dev/null +++ b/src/shared/ripgrep-search-diagnostics.ts @@ -0,0 +1,34 @@ +import type { SearchAccumulator } from './text-search' + +const MAX_SEARCH_ERROR_BYTES = 4096 + +export class RipgrepSearchDiagnostics { + private readonly bytes = Buffer.alloc(MAX_SEARCH_ERROR_BYTES) + private length = 0 + + append(chunk: Buffer | string): void { + if (this.length >= this.bytes.length) { + return + } + this.length += + typeof chunk === 'string' + ? this.bytes.write(chunk, this.length, this.bytes.length - this.length, 'utf8') + : chunk.copy(this.bytes, this.length, 0, this.bytes.length - this.length) + } + + failure( + code: number | null, + signal: NodeJS.Signals | null, + acc: SearchAccumulator + ): Error | null { + if (code === 0 || code === 1 || (signal && acc.truncated)) { + return null + } + if (acc.totalMatches > 0) { + acc.truncated = true + return null + } + const detail = this.bytes.toString('utf8', 0, this.length).trim() + return new Error(`Search failed (${signal ?? code})${detail ? `: ${detail}` : ''}`) + } +} diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index a039594604f..0295247a2e8 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -239,6 +239,7 @@ export const UiUpdateFields = z usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), codexTerminalServerIsolationNoticeSeen: z.boolean().optional(), + codexSharedSettingsNoticeSeen: z.boolean().optional(), petVisible: z.boolean().optional(), petId: z.string().optional(), customPets: UnknownRecordArray.optional(), diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index cb59809a072..4fc5b09fda9 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -10,7 +10,7 @@ import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts import type { TabGroupLayoutNode } from './tab-types' import type { TerminalExitCause } from './terminal-exit-cause' import type { TerminalPaneLayoutNode } from './terminal-tab-types' -import type { TuiAgent } from './tui-agent' +import type { TerminalAgent, TuiAgent } from './terminal-agent' export type RuntimeTerminalSummary = { handle: string @@ -33,8 +33,8 @@ export type RuntimeTerminalSummary = { writable: boolean lastOutputAt: number | null preview: string - /** Host-resolved agent identity for action consumers; absent when unknown or unsupported. */ - agentIdentity?: TuiAgent + /** Host-resolved observed agent identity; absent when unknown. Does not imply launch support. */ + agentIdentity?: TerminalAgent /** Absent while running or when the host predates the field; never infer a clean finish. */ exitCause?: TerminalExitCause /** Absent when the host predates the field or could not name the execution host. */ diff --git a/src/shared/terminal-agent.ts b/src/shared/terminal-agent.ts new file mode 100644 index 00000000000..ede9549e18b --- /dev/null +++ b/src/shared/terminal-agent.ts @@ -0,0 +1,5 @@ +import type { TuiAgent } from './tui-agent' +export type { TuiAgent } from './tui-agent' + +// Why: recognizing a manually started agent must not register an Orca launcher. +export type TerminalAgent = TuiAgent | 'dsb' diff --git a/src/shared/terminal-cursor-line-context.ts b/src/shared/terminal-cursor-line-context.ts index 230c85c7895..28fc2402fe2 100644 --- a/src/shared/terminal-cursor-line-context.ts +++ b/src/shared/terminal-cursor-line-context.ts @@ -8,14 +8,14 @@ type TerminalCursorCell = { isFgDefault(): boolean | number } -type TerminalCursorLine = { +type TerminalCursorLine = { readonly isWrapped: boolean readonly length: number - getCell(column: number): TerminalCursorCell | undefined + getCell(column: number, reusableCell?: Cell): Cell | undefined translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string } -export type TerminalCursorContextSource = { +export type TerminalCursorContextSource = { readonly rows: number readonly modes: { readonly showCursor: boolean } readonly buffer: { @@ -24,15 +24,21 @@ export type TerminalCursorContextSource = { readonly cursorX: number readonly cursorY: number readonly viewportY: number - getLine(row: number): TerminalCursorLine | undefined + getLine(row: number): TerminalCursorLine | undefined + getNullCell?(): Cell } } } -function undimmedText(line: TerminalCursorLine, fromX = 0, trimRight = true): string { +function undimmedText( + line: TerminalCursorLine, + fromX = 0, + trimRight = true, + reusableCell?: Cell +): string { let text = '' for (let x = fromX; x < line.length; x += 1) { - const cell = line.getCell(x) + const cell = line.getCell(x, reusableCell) if (!cell || cell.isDim() || cell.getWidth() === 0) { continue } @@ -41,9 +47,12 @@ function undimmedText(line: TerminalCursorLine, fromX = 0, trimRight = true): st return trimRight ? text.trimEnd() : text } -function firstVisibleCellIsBold(line: TerminalCursorLine): boolean { +function firstVisibleCellIsBold( + line: TerminalCursorLine, + reusableCell?: Cell +): boolean { for (let x = 0; x < line.length; x += 1) { - const cell = line.getCell(x) + const cell = line.getCell(x, reusableCell) if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) { continue } @@ -52,9 +61,12 @@ function firstVisibleCellIsBold(line: TerminalCursorLine): boolean { return false } -function firstVisibleCellHasCustomForeground(line: TerminalCursorLine): boolean { +function firstVisibleCellHasCustomForeground( + line: TerminalCursorLine, + reusableCell?: Cell +): boolean { for (let x = 0; x < line.length; x += 1) { - const cell = line.getCell(x) + const cell = line.getCell(x, reusableCell) if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) { continue } @@ -63,8 +75,8 @@ function firstVisibleCellHasCustomForeground(line: TerminalCursorLine): boolean return false } -export function readTerminalCursorLineContext( - terminal: TerminalCursorContextSource, +export function readTerminalCursorLineContext( + terminal: TerminalCursorContextSource, rowsAroundCursor: number ): TerminalCursorContext | null { const buffer = terminal.buffer.active @@ -73,6 +85,7 @@ export function readTerminalCursorLineContext( if (!cursorLine) { return null } + const reusableCell = buffer.getNullCell?.() const rows: string[] = [] const typedRows: string[] = [] const promptGlyphBoldRows: boolean[] = [] @@ -83,8 +96,8 @@ export function readTerminalCursorLineContext( const line = buffer.getLine(row) const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false rows.push(line?.translateToString(!nextLineIsWrapped) ?? '') - typedRows.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '') - promptGlyphBoldRows.push(line ? firstVisibleCellIsBold(line) : false) + typedRows.push(line ? undimmedText(line, 0, !nextLineIsWrapped, reusableCell) : '') + promptGlyphBoldRows.push(line ? firstVisibleCellIsBold(line, reusableCell) : false) rowsWrapped.push(line?.isWrapped ?? false) } const rowsBelow: string[] = [] @@ -96,9 +109,11 @@ export function readTerminalCursorLineContext( const line = buffer.getLine(row) const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false rowsBelow.push(line?.translateToString(!nextLineIsWrapped) ?? '') - typedRowsBelow.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '') + typedRowsBelow.push(line ? undimmedText(line, 0, !nextLineIsWrapped, reusableCell) : '') rowsBelowWrapped.push(line?.isWrapped ?? false) - rowsBelowCustomForeground.push(line ? firstVisibleCellHasCustomForeground(line) : false) + rowsBelowCustomForeground.push( + line ? firstVisibleCellHasCustomForeground(line, reusableCell) : false + ) } return { rows, @@ -113,7 +128,8 @@ export function readTerminalCursorLineContext( afterCursor: undimmedText( cursorLine, buffer.cursorX, - !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false) + !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false), + reusableCell ), rawAfterCursor: cursorLine.translateToString( !(buffer.getLine(cursorRow + 1)?.isWrapped ?? false), diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts index 10fb0cd6e58..4fab319ab5b 100644 --- a/src/shared/terminal-restore-parity-fixture.ts +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -47,10 +47,17 @@ export function writeToTerminal(terminal: Terminal, data: string): Promise return new Promise((resolve) => terminal.write(data, resolve)) } -export async function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise { - for (const chunk of chunks) { - await writeToTerminal(terminal, chunk) - } +export function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise { + return new Promise((resolve) => { + if (chunks.length === 0) { + resolve() + return + } + // The final FIFO callback also fences async parser handlers in earlier chunks. + for (let index = 0; index < chunks.length; index++) { + terminal.write(chunks[index]!, index === chunks.length - 1 ? resolve : undefined) + } + }) } /** Bottom-anchored visible screen rows (baseY, not viewportY — scroll intent diff --git a/src/shared/terminal-restore-parity-writes.test.ts b/src/shared/terminal-restore-parity-writes.test.ts new file mode 100644 index 00000000000..342e7effd54 --- /dev/null +++ b/src/shared/terminal-restore-parity-writes.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + normalBufferStylesTrimmed, + visibleRowStyles, + visibleRowWraps, + visibleRows, + writeChunksToTerminal +} from './terminal-restore-parity-fixture' + +type AsyncOscParser = { + registerOscHandler( + identifier: number, + callback: (data: string) => boolean | Promise + ): { dispose(): void } +} + +describe('terminal parity fixture writes', () => { + it('queues every original chunk and resolves only after the last callback', async () => { + const { terminal } = createRendererParityTerminal({ cols: 8, rows: 2 }) + const writes: { data: string | Uint8Array; callback?: () => void }[] = [] + try { + vi.spyOn(terminal, 'write').mockImplementation((data, callback) => { + writes.push({ data, callback }) + }) + const chunks = ['A\x1b[', '', '31mB', '\x1b[0mC'] + let completed = false + const pending = writeChunksToTerminal(terminal, chunks).then(() => { + completed = true + }) + expect(writes.map(({ data }) => data)).toEqual(chunks) + expect(writes.slice(0, -1).every(({ callback }) => callback === undefined)).toBe(true) + await Promise.resolve() + expect(completed).toBe(false) + writes.at(-1)?.callback?.() + await pending + expect(completed).toBe(true) + } finally { + terminal.dispose() + vi.restoreAllMocks() + } + }) + + it('completes an empty batch without writing or scheduling work', async () => { + const { terminal } = createRendererParityTerminal({ cols: 8, rows: 2 }) + try { + const write = vi.spyOn(terminal, 'write') + await writeChunksToTerminal(terminal, []) + expect(write).not.toHaveBeenCalled() + } finally { + terminal.dispose() + vi.restoreAllMocks() + } + }) + + it('waits for an asynchronous parser barrier before exposing later chunks', async () => { + const { terminal } = createRendererParityTerminal({ cols: 8, rows: 2 }) + let signalEntered = (): void => {} + const entered = new Promise((resolve) => { + signalEntered = resolve + }) + let releaseParser = (): void => {} + const barrier = new Promise((resolve) => { + releaseParser = () => resolve(true) + }) + // xterm supports async handlers; its shipped public typings still say boolean. + const parser: AsyncOscParser = terminal.parser + const registration = parser.registerOscHandler(777, () => { + signalEntered() + return barrier + }) + try { + let completed = false + const pending = writeChunksToTerminal(terminal, [ + 'A\x1b]777;pause', + '\x07B', + '\x1b[31mC', + 'D' + ]).then(() => { + completed = true + }) + await entered + expect(visibleRows(terminal)[0]).toBe('A') + expect(completed).toBe(false) + releaseParser() + await pending + expect(visibleRows(terminal)[0]).toBe('ABCD') + expect(terminal.buffer.active.getLine(0)?.getCell(3)?.getFgColor()).toBe(1) + } finally { + releaseParser() + registration.dispose() + terminal.dispose() + } + }) + + it.each([ + ['A\x1b[', '38;2;11;22;33m界', 'é👩‍💻', '\x1b[0m\r\nnext'], + ['history\r\n'.repeat(8), '\x1b[?1049h\x1b[2J\x1b[H', '\x1b[48;5;2m ALT ', '\x1b[0m'], + ['\x1b[?1049hALT', '\x1b[?1049l', '\x1b[7;4;9;53m ', '\x1b[0mnormal'], + ['\x1b]8;;https://example.com\x07', 'link', '\x1b]8;;', '\x07 tail'], + ['0123456789ABCDEFGHIJKLMN', '\x1b[2A\x1b[2K', '\x1b[3;5H', 'end\x1b[?2004h'] + ])( + 'matches serial callbacks for split controls, styles and buffer transitions: %j', + async (...chunks) => { + const batched = createRendererParityTerminal({ cols: 12, rows: 4 }) + const serial = createRendererParityTerminal({ cols: 12, rows: 4 }) + try { + for (const chunk of chunks) { + await new Promise((resolve) => serial.terminal.write(chunk, resolve)) + } + await writeChunksToTerminal(batched.terminal, chunks) + expect(visibleRows(batched.terminal)).toEqual(visibleRows(serial.terminal)) + expect(visibleRowStyles(batched.terminal)).toEqual(visibleRowStyles(serial.terminal)) + expect(visibleRowWraps(batched.terminal)).toEqual(visibleRowWraps(serial.terminal)) + expect(normalBufferRowsTrimmed(batched.terminal)).toEqual( + normalBufferRowsTrimmed(serial.terminal) + ) + expect(normalBufferStylesTrimmed(batched.terminal)).toEqual( + normalBufferStylesTrimmed(serial.terminal) + ) + expect(cursorPosition(batched.terminal)).toEqual(cursorPosition(serial.terminal)) + expect(batched.terminal.modes).toEqual(serial.terminal.modes) + expect(batched.serializeAddon.serialize()).toBe(serial.serializeAddon.serialize()) + } finally { + batched.terminal.dispose() + serial.terminal.dispose() + } + } + ) +}) diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 9e5d19fb59d..f9f72ed4a45 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -10,6 +10,7 @@ import { isCursorAgentTitle, isDshTerminalTitle } from './agent-title-core' +import { isDeepSeekBuildTerminalTitle } from './dsb-terminal-title' export { DSH_WHALE, isDshTerminalTitle } from './agent-title-core' import { isOpenCodeNativeTitle } from './opencode-terminal-title' @@ -19,7 +20,7 @@ import { } from './pi-compatible-synthetic-title' import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter' import { memoizeTitleClassification } from './terminal-title-classification-memo' -import type { TuiAgent } from './tui-agent' +import type { TerminalAgent } from './terminal-agent' export const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) const CLAUDE_MANAGEMENT_TITLE_RE = @@ -46,7 +47,7 @@ export function isGeminiTerminalTitle(title: string): boolean { if (isQoderTerminalTitle(title)) { return false } - if (isDshTerminalTitle(title)) { + if (isDshTerminalTitle(title) || isDeepSeekBuildTerminalTitle(title)) { return false } // Why: Gemini OSC glyphs are stronger evidence than any cwd/session text. @@ -124,6 +125,10 @@ function computeIsClaudeAgent(title: string): boolean { if (title.startsWith('. ') || title.startsWith('* ')) { return true } + // Why: a working DeepSeek Build title uses Claude's braille frame. + if (isDeepSeekBuildTerminalTitle(title)) { + return false + } if (containsAgentSpinnerGlyph(title)) { // Why: named non-Claude agents carry braille spinners too. Gate Cursor by its // identity title, not the token, so a Claude title mentioning a cursor stays Claude. @@ -177,6 +182,10 @@ function computeAgentLabel(title: string): string | null { if (isQoderTerminalTitle(title)) { return title.includes('Qoder CLI CN') ? 'Qoder CLI CN' : 'Qoder CLI' } + // Why: the DSB matcher distinguishes native prefixes from glyphs inside task text. + if (isDeepSeekBuildTerminalTitle(title)) { + return 'DeepSeek Build' + } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } @@ -255,7 +264,7 @@ function computeAgentLabel(title: string): string | null { export const getAgentLabel: (title: string) => string | null = memoizeTitleClassification(computeAgentLabel) -const TITLE_LABEL_TO_AGENT: Partial> = { +const TITLE_LABEL_TO_AGENT: Partial> = { 'DeepSeek Harness': 'dsh', 'Claude Code': 'claude', OpenClaude: 'openclaude', @@ -276,6 +285,7 @@ const TITLE_LABEL_TO_AGENT: Partial> = { Cursor: 'cursor', Droid: 'droid', Hermes: 'hermes', + 'DeepSeek Build': 'dsb', Pi: 'pi', OMP: 'omp' } @@ -292,7 +302,7 @@ function hasGenericClaudeStatusPrefix(title: string): boolean { export { isClaudeIdentityFrameTitle } from './agent-title-core' -function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): boolean { +function isGenericClaudeStatusClaim(title: string, titleAgent: TerminalAgent | null): boolean { return ( titleAgent === 'claude' && hasGenericClaudeStatusPrefix(title) && @@ -300,7 +310,7 @@ function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): ) } -export function resolveTerminalTitleAgentType(title: string): TuiAgent | null { +export function resolveTerminalTitleAgentType(title: string): TerminalAgent | null { const label = getAgentLabel(title) const parsed = label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null return resolveCanonicalPaneAgentIdentity({ @@ -317,7 +327,7 @@ export function resolveTerminalTitleAgentType(title: string): TuiAgent | null { * that something is running, not proof the agent is Claude — so a task or * worktree title cannot become Claude without an explicit "Claude Code" name. */ -function computeExplicitTerminalTitleAgentType(title: string): TuiAgent | null { +function computeExplicitTerminalTitleAgentType(title: string): TerminalAgent | null { const titleAgent = resolveTerminalTitleAgentType(title) if (isGenericClaudeStatusClaim(title, titleAgent)) { return null @@ -326,5 +336,5 @@ function computeExplicitTerminalTitleAgentType(title: string): TuiAgent | null { } /** Pure in `title` — memoized so repeated selector reads skip the canonical/title parse. */ -export const resolveExplicitTerminalTitleAgentType: (title: string) => TuiAgent | null = +export const resolveExplicitTerminalTitleAgentType: (title: string) => TerminalAgent | null = memoizeTitleClassification(computeExplicitTerminalTitleAgentType) diff --git a/src/shared/text-search-dense-matches.test.ts b/src/shared/text-search-dense-matches.test.ts new file mode 100644 index 00000000000..e32cd6b35e4 --- /dev/null +++ b/src/shared/text-search-dense-matches.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess } from './child-process/run-process' +import { buildRgArgs, createAccumulator, ingestRgJsonLine } from './text-search' + +describe('text search match budgets', () => { + it('keeps dense-line columns after a leading U+FEFF from real rg', async () => { + const { rgPath } = await import('@vscode/ripgrep-universal') + const root = await mkdtemp(join(tmpdir(), 'orca-rg-dense-bom-')) + const filename = join(root, '\ufeffdense.txt') + try { + await writeFile(filename, `header\n\ufeff${'x '.repeat(10_000)}`) + const result = await runProcess({ + program: rgPath, + args: buildRgArgs('x', '.', {}), + cwd: root + }) + expect(result.code).toBe(0) + const accumulator = createAccumulator() + for (const line of result.stdout.split('\n')) { + if (ingestRgJsonLine(line, root, accumulator, 2000) === 'stop') { + break + } + } + expect(accumulator.totalMatches).toBe(2000) + expect(accumulator.fileMap.get(filename)?.matches[0]).toMatchObject({ + line: 2, + column: 2, + matchLength: 1 + }) + expect(accumulator.fileMap.get(filename)?.matches[1999]?.column).toBe(4000) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('preserves the requested budget from real rg dense-line output', async () => { + const { rgPath } = await import('@vscode/ripgrep-universal') + const root = await mkdtemp(join(tmpdir(), 'orca-rg-dense-')) + try { + await writeFile(join(root, 'dense.txt'), 'x '.repeat(10_000)) + const result = await runProcess({ + program: rgPath, + args: buildRgArgs('x', root, {}), + cwd: root + }) + expect(result.code).toBe(0) + const accumulator = createAccumulator() + for (const line of result.stdout.split('\n')) { + if (ingestRgJsonLine(line, root, accumulator, 2000) === 'stop') { + break + } + } + expect(accumulator.totalMatches).toBe(2000) + expect(accumulator.truncated).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('allows more than 100 matching lines in one file under the global budget', async () => { + const { rgPath } = await import('@vscode/ripgrep-universal') + const root = await mkdtemp(join(tmpdir(), 'orca-rg-lines-')) + try { + await writeFile(join(root, 'many.txt'), 'needle\n'.repeat(150)) + const result = await runProcess({ + program: rgPath, + args: buildRgArgs('needle', root, {}), + cwd: root + }) + expect(result.code).toBe(0) + const accumulator = createAccumulator() + for (const line of result.stdout.split('\n')) { + ingestRgJsonLine(line, root, accumulator, 2000) + } + expect(accumulator.totalMatches).toBe(150) + expect(accumulator.truncated).toBe(false) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +function denseRecord(count: number): string { + return JSON.stringify({ + type: 'match', + data: { + path: { text: '/root/dense.txt' }, + lines: { text: 'x '.repeat(count) }, + line_number: 1, + submatches: Array.from({ length: count }, (_, index) => ({ + match: { text: 'x' }, + start: index * 2, + end: index * 2 + 1 + })) + } + }) +} + +it('retains the first 2000 matches from a dense line beyond the normal JSON budget', () => { + const accumulator = createAccumulator() + expect(ingestRgJsonLine(denseRecord(10_000), '/root', accumulator, 2000)).toBe('stop') + expect(accumulator.totalMatches).toBe(2000) + expect(accumulator.truncated).toBe(true) + const matches = accumulator.fileMap.get('/root/dense.txt')?.matches + expect(matches?.map((match) => match.column)).toEqual( + Array.from({ length: 2000 }, (_, index) => index * 2 + 1) + ) +}) + +it.each([ + (record: string) => record.slice(0, -1), + (record: string) => `${record.slice(0, -2)},invalid}`, + (record: string) => `${record.slice(0, -1)},"data":{}}`, + (record: string) => `${record.slice(0, -2)},"submatches":[]}}` +])( + 'rejects invalid tails or duplicate envelope keys without retaining early matches', + (corrupt) => { + const accumulator = createAccumulator() + ingestRgJsonLine(corrupt(denseRecord(10_000)), '/root', accumulator, 2000) + expect(accumulator.totalMatches).toBe(0) + expect(accumulator.truncated).toBe(true) + } +) diff --git a/src/shared/text-search-invalid-filename.test.ts b/src/shared/text-search-invalid-filename.test.ts new file mode 100644 index 00000000000..d9faa51efde --- /dev/null +++ b/src/shared/text-search-invalid-filename.test.ts @@ -0,0 +1,19 @@ +import { expect, it } from 'vitest' +import { createAccumulator, ingestRgJsonLine } from './text-search' + +it('reports byte-only filenames as incomplete without inventing a replacement-character path', () => { + const acc = createAccumulator() + const line = JSON.stringify({ + type: 'match', + data: { + path: { bytes: Buffer.from([0xff, 0x2e, 0x74, 0x78, 0x74]).toString('base64') }, + lines: { text: 'needle\n' }, + line_number: 1, + submatches: [{ start: 0, end: 6 }] + } + }) + expect(ingestRgJsonLine(line, '/root', acc, 20)).toBe('continue') + expect(acc.truncated).toBe(true) + expect(acc.totalMatches).toBe(0) + expect(acc.fileMap.size).toBe(0) +}) diff --git a/src/shared/text-search-paths.test.ts b/src/shared/text-search-paths.test.ts new file mode 100644 index 00000000000..b6fdc3bb1c2 --- /dev/null +++ b/src/shared/text-search-paths.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { createAccumulator, ingestRgJsonLine } from './text-search' +import { resolveSearchResultPath } from './text-search-paths' + +describe('text search result paths', () => { + it.each([ + ['/root/repo', './src/a.ts', '/root/repo/src/a.ts'], + ['/root/repo', '/root/repo/src/a.ts', '/root/repo/src/a.ts'], + ['C:\\repo', './src/a.ts', 'C:\\repo\\src\\a.ts'], + ['C:\\repo', 'C:/repo/src/a.ts', 'C:/repo/src/a.ts'], + ['\\\\wsl.localhost\\Ubuntu\\repo', './src/a.ts', '\\\\wsl.localhost\\Ubuntu\\repo\\src\\a.ts'] + ])('resolves %s and %s on the owning host', (root, reported, expected) => { + expect(resolveSearchResultPath(root, reported)).toBe(expected) + }) + + it('translates an absolute WSL path before resolving its host path', () => { + const acc = createAccumulator() + ingestRgJsonLine( + JSON.stringify({ + type: 'match', + data: { + path: { text: '/repo/src/a.ts' }, + lines: { text: 'match\n' }, + line_number: 1, + submatches: [{ start: 0, end: 5 }] + } + }), + '\\\\wsl.localhost\\Ubuntu\\repo', + acc, + 20, + (path) => `\\\\wsl.localhost\\Ubuntu${path.replaceAll('/', '\\')}` + ) + expect([...acc.fileMap.values()][0]?.relativePath).toBe('src/a.ts') + expect([...acc.fileMap.keys()]).toEqual(['\\\\wsl.localhost\\Ubuntu\\repo\\src\\a.ts']) + }) +}) diff --git a/src/shared/text-search-paths.ts b/src/shared/text-search-paths.ts index c556a10dedf..4e7cd154158 100644 --- a/src/shared/text-search-paths.ts +++ b/src/shared/text-search-paths.ts @@ -1,20 +1,28 @@ import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from './cross-platform-path' function pathFlavor(rootPath: string): typeof posix | typeof win32 { - if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { + if (isWindowsAbsolutePathLike(rootPath)) { return win32 } return posix } -export function normalizeRelativePath(path: string): string { - return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +export function normalizeRelativePath(path: string, rootPath?: string): string { + const separators = + rootPath !== undefined && !isWindowsAbsolutePathLike(rootPath) ? /\/+/g : /[\\/]+/g + return path.replace(separators, '/').replace(/^\/+/, '') } export function relativeToSearchRoot(rootPath: string, absolutePath: string): string { return pathFlavor(rootPath).relative(rootPath, absolutePath) } +export function resolveSearchResultPath(rootPath: string, reportedPath: string): string { + const paths = pathFlavor(rootPath) + return paths.isAbsolute(reportedPath) ? reportedPath : paths.resolve(rootPath, reportedPath) +} + export function joinSearchRoot(rootPath: string, relativePath: string): string { return pathFlavor(rootPath).join(rootPath, relativePath) } diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index b6f9e9e6b23..c811425a3d0 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -7,13 +7,20 @@ * can't re-diverge (notably the relay's old execFile maxBuffer that dropped matches). * Design doc: docs/design/share-text-search.md. */ -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { parseRipgrepMatchJson, type RipgrepMatchMessage } from './ripgrep-dense-match-json' import { normalizeSearchResult } from './search-match-count' import { escapeRegex } from './string-utils' import type { SearchFileResult, SearchOptions, SearchResult } from './code-search-types' import { pushSearchMatch } from './text-search-match-accumulator' import { splitSearchGlobPatterns, toGitGlobPathspecs } from './text-search-glob-patterns' -import { joinSearchRoot, normalizeRelativePath, relativeToSearchRoot } from './text-search-paths' +import { + joinSearchRoot, + normalizeRelativePath, + relativeToSearchRoot, + resolveSearchResultPath +} from './text-search-paths' +import { ripgrepMatchRanges } from './ripgrep-match-offsets' +import { decodeRipgrepLine } from './ripgrep-line-decoding' export type SearchAccumulator = { fileMap: Map @@ -27,7 +34,6 @@ export function createAccumulator(): SearchAccumulator { // ─── Constants shared by both callers ──────────────────────────────── -export const MAX_MATCHES_PER_FILE = 100 export const DEFAULT_SEARCH_MAX_RESULTS = 2000 export const SEARCH_TIMEOUT_MS = 15_000 export const SEARCH_JSON_STRUCTURE_LIMITS = { @@ -51,17 +57,15 @@ export type SearchOptionsLike = Pick< /** * Build the complete rg argv (flags + `--` + query + target) for both callers to spawn as-is. * - * Constraint: pass `rootPath` unchanged as `target` — do NOT WSL-translate it; only the rg - * invocation is routed through `wslAwareSpawn`, and output paths are translated back in `ingestRgJsonLine`. + * Use target `.` with cwd set to the search root so anchored globs match root-relative paths. */ export function buildRgArgs(query: string, target: string, opts: SearchOptionsLike): string[] { const args: string[] = [ + '--no-config', '--json', '--hidden', '--glob', '!.git', - '--max-count', - String(MAX_MATCHES_PER_FILE), '--max-filesize', `${Math.floor(SEARCH_MAX_FILE_SIZE / 1024 / 1024)}M` ] @@ -101,7 +105,7 @@ export function ingestRgJsonLine( rootPath: string, acc: SearchAccumulator, maxResults: number, - transformAbsPath?: (p: string) => string + transformAbsPath?: (p: string) => string | null ): 'continue' | 'stop' { if (acc.totalMatches >= maxResults) { return 'stop' @@ -109,19 +113,11 @@ export function ingestRgJsonLine( if (!line) { return 'continue' } - let msg: { - type?: string - data?: { - path?: { text?: string } - submatches?: { start: number; end: number }[] - line_number?: number - lines?: { text?: string } - } - } + let msg: RipgrepMatchMessage try { - assertJsonTextStructureWithinLimits(line, SEARCH_JSON_STRUCTURE_LIMITS) - msg = JSON.parse(line) + msg = parseRipgrepMatchJson(line, maxResults - acc.totalMatches, SEARCH_JSON_STRUCTURE_LIMITS) } catch { + acc.truncated = true return 'continue' } if (msg.type !== 'match' || !msg.data) { @@ -130,19 +126,22 @@ export function ingestRgJsonLine( const data = msg.data const rawPath = data.path?.text if (typeof rawPath !== 'string') { + // File APIs accept strings, so byte-only filenames cannot be opened losslessly. + acc.truncated = true return 'continue' } - const absPath = transformAbsPath ? transformAbsPath(rawPath) : rawPath - const relPath = normalizeRelativePath(relativeToSearchRoot(rootPath, absPath)) - const lineContent = (data.lines?.text ?? '').replace(/\n$/, '') - const lineNumber = data.line_number ?? 0 - let submatches = data.submatches ?? [] - if (submatches.length === 0) { - // Why: some rg matches report a line but no submatch ranges; surface a navigable line-level result instead of a count-0 row. - submatches = [{ start: 0, end: lineContent.length > 0 ? 1 : 0 }] + const mappedPath = transformAbsPath ? transformAbsPath(rawPath) : rawPath + if (mappedPath === null) { + acc.truncated = true + return 'continue' } - - for (const sub of submatches) { + const absPath = resolveSearchResultPath(rootPath, mappedPath) + const relPath = normalizeRelativePath(relativeToSearchRoot(rootPath, absPath), rootPath) + const { text: lineContent, readOffset } = decodeRipgrepLine(data.lines) + const lineNumber = data.line_number ?? 0 + for (const sub of ripgrepMatchRanges(lineContent, data.submatches ?? [], readOffset, () => { + acc.truncated = true + })) { let fileResult = acc.fileMap.get(absPath) if (!fileResult) { fileResult = { filePath: absPath, relativePath: relPath, matches: [], matchCount: 0 } @@ -263,7 +262,7 @@ export function ingestGitGrepLine( if (nullIdx === -1) { return 'continue' } - const relPath = normalizeRelativePath(line.substring(0, nullIdx)) + const relPath = normalizeRelativePath(line.substring(0, nullIdx), rootPath) const rest = line.substring(nullIdx + 1) const secondNullIdx = rest.indexOf('\0') let lineNumberText: string diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index c381454e9c4..8c56c1d9d95 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -17,6 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import { appliedSessionOptionProps, buildFlagPromptStartupPlan } from './flag-prompt-startup' export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' @@ -35,10 +36,6 @@ export type AgentStartupPlan = { sessionOptions?: Record } -function appliedSessionOptionProps(values: Record) { - return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} -} - export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -116,15 +113,16 @@ export function buildAgentStartupPlan(args: { } if (config.promptInjectionMode === 'flag-prompt') { - return { + return buildFlagPromptStartupPlan({ agent, - launchCommand: `${launchCommand} --prompt ${quotedPrompt}`, - expectedProcess: config.expectedProcess, - followupPrompt: null, + launchCommand, + quotedPrompt, + prompt: trimmedPrompt, + shell, launchConfig, - ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) - } + sessionOptions: baseCommand.appliedSessionOptions, + agentEnv: args.agentEnv + }) } if (config.promptInjectionMode === 'hermes-query') { diff --git a/src/shared/update-status-types.ts b/src/shared/update-status-types.ts index 74ee382ec58..3e0a7f46f04 100644 --- a/src/shared/update-status-types.ts +++ b/src/shared/update-status-types.ts @@ -88,6 +88,8 @@ export type UpdateStatus = ( version?: string /** Omitted by older hosts and for failures whose retryability is unknown. */ retryable?: boolean + /** A staged update can retry installation without downloading again; older hosts omit this. */ + retryAction?: 'install' userInitiated?: boolean activeNudgeId?: string recovery?: LinuxPackageInstallRecovery diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts new file mode 100644 index 00000000000..5d79753797e --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts @@ -0,0 +1,84 @@ +// A current client's `agentSession.send`, built by the outbox clients send from, against a real +// published release. The release's schema must take it and its admission must re-derive the +// fingerprint the client declared: a send it rejects, or digests differently and refuses as an +// operation conflict, loses the user's message. + +import { beforeAll, describe, expect, it } from 'vitest' +import { resolveBaselineReleaseRef } from './release-checkout' +import { installableHost, structuredHostStub } from './structured-agent-session-host-fixture' +import { + resetOperationIds, + sendParams, + SESSION, + WORKSPACE +} from './structured-agent-session-surface-manifest' +import { + loadAgentSessionWireBuild, + WORKING_TREE, + type AgentSessionWireBuild, + type RpcReply, + type SentMessage +} from './versioned-agent-session-wire' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +let current: AgentSessionWireBuild +let baseline: AgentSessionWireBuild + +beforeAll(async () => { + current = await loadAgentSessionWireBuild(WORKING_TREE) + baseline = await loadAgentSessionWireBuild(resolveBaselineReleaseRef()) +}, SUITE_TIMEOUT_MS) + +async function send(build: AgentSessionWireBuild, params: unknown): Promise { + const replies: RpcReply[] = [] + await build + // Releases before the setting gate was dropped admit structured calls only with it on. + .createDispatcher({ + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }) + }) + .dispatchStreaming( + { id: 'request-send', authToken: 'cross-version-token', method: 'agentSession.send', params }, + (raw) => replies.push(JSON.parse(raw)), + { clientKind: 'runtime', clientCapabilities: current.capabilities } + ) + return replies +} + +describe('a current client sending a message', () => { + it.each([ + ['a plain message', undefined], + // `delivery` joins the operation fingerprint, so a builder that drops it is refused by a host + // that digests it. + ['a message held as a draft while a turn runs', 'queue-if-active' as const] + ])('is accepted for %s, under the fingerprint each build re-derives', async (_case, delivery) => { + // Anti-vacuous: the release must have the method, or there is no older host to send to. + expect(baseline.methodNames).toContain('agentSession.send') + for (const build of [current, baseline]) { + resetOperationIds() + const hostCalls = structuredHostStub(SESSION, WORKSPACE) + await build.installStructuredHost(installableHost(hostCalls)) + try { + const replies = await send(build, sendParams('hi', 1, delivery)) + expect(replies, `${build.label}: ${JSON.stringify(replies)}`).toMatchObject([{ ok: true }]) + expect(hostCalls.send, `${build.label}: the send reached the host`).toHaveBeenCalledTimes(1) + const sent: SentMessage = hostCalls.send.mock.calls[0]?.[1] + expect(sent.delivery, `${build.label}: delivery reached the host`).toBe(delivery) + // Past the fingerprint check, admission stops at the journal this harness never opens. + expect(await build.admitSend(sent), `${build.label}: send admission`).toMatchObject({ + ok: false, + refusal: { details: { reason: 'sessionNotAttached' } } + }) + // Negative control: proves the fingerprint check ran before admission reached the journal. + const wrong = { ...sent.envelope, payloadFingerprint: '0'.repeat(64) } + expect(await build.admitSend({ ...sent, envelope: wrong }), build.label).toMatchObject({ + refusal: { details: { reason: 'fingerprintMismatch' } } + }) + } finally { + await build.installStructuredHost(null) + } + } + }) +}) diff --git a/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts index c9bdc70b6b6..6204a15f8b7 100644 --- a/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/qoder-history-search-downgrade.unit.test.ts @@ -180,7 +180,7 @@ test.each(PRE_QODER_HOSTS)( expect( await client.searchSessions({ query: 'proof', filters: { agents: ['codex'] }, within }) ).toMatchObject({ hits: [{ agent: 'codex' }] }) - expect(call).toHaveBeenCalledTimes(1) + expect(call).toHaveBeenCalledTimes(2) } ) diff --git a/tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts new file mode 100644 index 00000000000..71554054d0d --- /dev/null +++ b/tests/e2e/cross-version-wire/session-search-agent-set-downgrade.unit.test.ts @@ -0,0 +1,205 @@ +import { createSessionSearchService } from '../../../src/main/ai-vault-search/session-search-service' +import { + addSyntheticSession, + openSessionSearchHarness +} from '../../../src/main/ai-vault-search/session-search-engine-test-fixture' +import { afterEach, expect, test, vi } from 'vitest' +import { + createSessionSearchClient, + unavailableSessionSearchStatus +} from '../../../src/shared/ai-vault-search-client' +import { AI_VAULT_AGENTS } from '../../../src/shared/ai-vault-types' +import { + searchHit, + searchResults, + fakeSearchService +} from '../../../src/shared/ai-vault-search-test-fixture' +import { + searchSessionService, + sessionSearchServiceStatus, + setSessionSearchService +} from '../../../src/main/ai-vault-search/session-search-service-registry' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +const LEGACY_REF = 'b49abdb1f4da6b3d62dfa9ccf3c74dc9e74d291c' +afterEach(() => setSessionSearchService(null)) + +test('the actual pre-Jcode client reads current host pages while current peers retain every provider', async () => { + const baseline = await importReleaseCheckoutModule( + await materializeReleaseCheckout(LEGACY_REF), + 'src/shared/ai-vault-search-contract.ts' + ) + const oldResponse = baseline.AiVaultSearchResponseSchema + if ( + !oldResponse || + typeof oldResponse !== 'object' || + !('safeParse' in oldResponse) || + typeof oldResponse.safeParse !== 'function' + ) { + throw new Error('Pinned release has no response parser') + } + const hits = (['codex', 'qoder', 'jcode'] as const).map((agent) => ({ ...searchHit(), agent })) + expect(oldResponse.safeParse({ ...searchResults(), hits: [hits[2]] })).toHaveProperty( + 'success', + false + ) + const service = fakeSearchService() + service.search.mockImplementation(async (request) => ({ + ...searchResults(), + hits: hits.filter( + (hit) => !request.filters?.agents?.length || request.filters.agents.includes(hit.agent) + ) + })) + setSessionSearchService(service) + const legacy = await searchSessionService({ query: 'proof' }, 'relay') + expect(oldResponse.safeParse(legacy)).toHaveProperty('success', true) + expect(legacy).toMatchObject({ hits: [{ agent: 'codex' }] }) + const client = createSessionSearchClient( + (method, params) => + method === 'aiVault.searchStatus' + ? sessionSearchServiceStatus(params, 'relay') + : searchSessionService(params, 'relay'), + 'relay' + ) + expect(await client.searchSessions({ query: 'proof' })).toMatchObject({ + hits: [{ agent: 'codex' }, { agent: 'qoder' }, { agent: 'jcode' }] + }) + expect(await sessionSearchServiceStatus({}, 'relay')).toMatchObject({ + supportedAgents: [...AI_VAULT_AGENTS] + }) +}) + +test.each(['runtime', 'relay'] as const)( + 'a new %s client refuses sole unsupported Jcode without widening the query', + async (transport) => { + const call = vi.fn(async () => unavailableSessionSearchStatus()) + expect( + await createSessionSearchClient(call, transport).searchSessions({ + query: 'proof', + filters: { agents: ['jcode'] } + }) + ).toEqual({ kind: 'unavailable', reason: 'unsupported-agent' }) + expect(call).toHaveBeenCalledExactlyOnceWith('aiVault.searchStatus', {}) + } +) + +test('agent-list negotiation takes precedence over the old flag and tolerates future advertised names', async () => { + const within = { kind: 'workspace' as const, worktreeId: 'folder:/task-owned/folder' } + const call = vi.fn(async (method: string) => + method === 'aiVault.searchStatus' + ? { + ...unavailableSessionSearchStatus(), + supportsQoderHistory: true, + supportedAgents: ['codex', 'jcode', 'future-agent'] + } + : searchResults() + ) + await createSessionSearchClient(call, 'runtime').searchSessions({ + query: 'proof', + cursor: 'page-2', + within, + filters: { agents: ['codex', 'qoder', 'jcode'], since: '2026-08-01T00:00:00Z', sort: 'newest' } + }) + expect(call).toHaveBeenLastCalledWith( + 'aiVault.searchSessions', + expect.objectContaining({ + cursor: 'page-2', + within, + filters: { agents: ['codex', 'jcode'], since: '2026-08-01T00:00:00Z', sort: 'newest' }, + supportedAgents: [...AI_VAULT_AGENTS] + }) + ) +}) + +test('a current host honors a negotiated client subset before index search and keeps scope identity', async () => { + const service = fakeSearchService() + setSessionSearchService(service) + await searchSessionService( + { + query: 'proof', + supportsQoderHistory: true, + supportedAgents: ['codex', 'future-agent'], + filters: { agents: ['codex', 'qoder', 'jcode'], scopePaths: ['/task-owned'] } + }, + 'runtime' + ) + expect(service.search).toHaveBeenLastCalledWith( + { query: 'proof', limit: 20, filters: { agents: ['codex'], scopePaths: ['/task-owned'] } }, + undefined + ) +}) + +test.each([{ supportedAgents: [] }, { supportedAgents: ['future-agent'] }])( + 'a client with no shared advertised agents returns no indexed rows for %j', + async ({ supportedAgents }) => { + const harness = await openSessionSearchHarness('ss-empty-peer-agents') + try { + addSyntheticSession(harness.db, { id: 1, agent: 'codex' }) + addSyntheticSession(harness.db, { id: 2, agent: 'jcode' }) + const retrieve = vi.spyOn(harness.engine, 'search') + const service = createSessionSearchService({ + engine: harness.engine, + indexer: { status: unavailableSessionSearchStatus, reconcile: async () => {} } + }) + setSessionSearchService(service) + const reply = await searchSessionService({ query: 'needle', supportedAgents }, 'runtime') + expect(reply).toMatchObject({ hits: [], page: { cursor: null, hasMore: false } }) + expect(retrieve).toHaveBeenCalledExactlyOnceWith({ + query: 'needle', + limit: 20, + filters: { scopePaths: [''] } + }) + expect(retrieve.mock.results[0]).toMatchObject({ + type: 'return', + value: { hits: [], page: { cursor: null, hasMore: false } } + }) + } finally { + setSessionSearchService(null) + await harness.close() + } + } +) + +test.each(['v1.4.211', LEGACY_REF])( + 'a missing status method still searches through the actual %s request parser', + async (ref) => { + const baseline = await importReleaseCheckoutModule( + await materializeReleaseCheckout(ref), + 'src/shared/ai-vault-search-contract.ts' + ) + const parser = baseline.AiVaultSearchRequestSchema + if ( + !parser || + typeof parser !== 'object' || + !('parse' in parser) || + typeof parser.parse !== 'function' + ) { + throw new Error('Pinned release has no request parser') + } + const call = vi.fn(async (method: string, request: Record) => { + if (method === 'aiVault.searchStatus') { + throw { code: 'method_not_found' } + } + parser.parse(request) + expect(request).toMatchObject({ + filters: { + agents: AI_VAULT_AGENTS.filter( + (agent) => !['codebuddy', 'zcode', 'qoder', 'jcode'].includes(agent) + ), + scopePaths: ['/execution-host/folder'] + } + }) + return searchResults() + }) + expect( + await createSessionSearchClient(call, 'relay').searchSessions({ + query: 'needle', + filters: { agents: [...AI_VAULT_AGENTS], scopePaths: ['/execution-host/folder'] } + }) + ).toMatchObject({ kind: 'results' }) + expect(call.mock.calls.map(([method]) => method)).toEqual([ + 'aiVault.searchStatus', + 'aiVault.searchSessions' + ]) + } +) 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 7eb4920deec..b999f63a651 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 @@ -11,6 +11,10 @@ import { attachFingerprintFields } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' import type { AgentSessionAttachParams } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope' +import { + createStructuredAgentSessionOutboxEntry, + structuredAgentSessionSendRequest +} from '../../../src/shared/structured-agent-session-outbox' export const SESSION = 'session-alpha' export const WORKSPACE = 'workspace-1' @@ -237,9 +241,21 @@ export function createIntentParams(): Record { return { envelope: envelope({ method: 'agentSession.create', fields, fence: null }), ...fields } } -export function sendParams(text: string, fence: number): Record { - const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } - return { envelope: envelope({ method: 'agentSession.send', fields: { body }, fence }), body } +/** Built by the outbox clients send from, so an older host is handed exactly what a current + * client puts on the wire, fingerprint included. */ +export function sendParams( + text: string, + fence: number, + sentDelivery?: 'queue-if-active' +): Record { + const entry = createStructuredAgentSessionOutboxEntry({ + clientMessageId: operationId(), + sessionId: SESSION, + text, + attachments: [], + queuedAt: NOW + }) + return structuredAgentSessionSendRequest({ ...entry, sentDelivery }, fence) } /** Schema-valid params per method; values only need to survive validation. */ diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts index 78efd420f83..da2f457ec8a 100644 --- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -1,3 +1,4 @@ +import type { sendPlan } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans' import { importReleaseCheckoutModule, materializeReleaseCheckout, @@ -17,6 +18,10 @@ export const WORKING_TREE = 'working-tree' as const * in current source is invisible to a release checkout's dispatcher. */ const STRUCTURED_HOST_REGISTRY = '/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts' +const MUTATION_PLANS = + '/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts' +const MUTATION_ADMISSION = + '/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts' export type RpcReply = { id: string @@ -60,6 +65,10 @@ export type AgentSessionWireBuild = { * the surface stays loadable, and throws rather than no-opping so a build with * no slot cannot read as a surface that answered. */ installStructuredHost: (host: unknown) => Promise + /** This build's own admission of the `agentSession.send` params its host was handed. With no + * journal it stops after the fingerprint check: a fingerprint it derives differently refuses + * as `fingerprintMismatch`, one it agrees with as `sessionNotAttached`. */ + admitSend: (sent: SentMessage) => Promise } type DispatcherModule = { @@ -86,6 +95,32 @@ function applyStructuredHost(module: Record, label: string, hos ;(install as (next: unknown) => void)(host) } +/** The `agentSession.send` params a host is handed. */ +export type SentMessage = Parameters[0] + +type SendAdmissionModules = { + sendPlan: (sent: SentMessage) => unknown + admitAndRunAgentSessionMutation: (request: { + plan: unknown + envelope: SentMessage['envelope'] + journal: () => undefined + }) => Promise +} + +async function admitSend( + plans: Record, + admission: Record, + sent: SentMessage +): Promise { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the build's own send plan and admission; with no `prepareSession` it reads only plan, envelope and journal. A drifted export or shape fails this check. + const build = { ...plans, ...admission } as unknown as SendAdmissionModules + return build.admitAndRunAgentSessionMutation({ + plan: build.sendPlan(sent), + envelope: sent.envelope, + journal: () => undefined + }) +} + function capabilityStrings(module: Record): readonly string[] { const declared = module.RUNTIME_CAPABILITIES if (!Array.isArray(declared) || declared.length === 0) { @@ -117,6 +152,13 @@ async function loadWorkingTreeBuild(): Promise { const registry = await import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry') applyStructuredHost(registry as unknown as Record, WORKING_TREE, host) + }, + admitSend: async (sent) => { + const [plans, admission] = await Promise.all([ + import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans'), + import('../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission') + ]) + return admitSend(plans, admission, sent) } } } @@ -146,6 +188,13 @@ async function loadReleaseBuild(checkout: ReleaseCheckout): Promise { + const [plans, admission] = await Promise.all([ + importReleaseCheckoutModule(checkout, MUTATION_PLANS), + importReleaseCheckoutModule(checkout, MUTATION_ADMISSION) + ]) + return admitSend(plans, admission, sent) } } } diff --git a/tests/e2e/markdown-doc-link-selection.spec.ts b/tests/e2e/markdown-doc-link-selection.spec.ts new file mode 100644 index 00000000000..2a79abe45e8 --- /dev/null +++ b/tests/e2e/markdown-doc-link-selection.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from './helpers/orca-app' +import type { Editor } from '@tiptap/core' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' + +type PageRichMarkdownLinkEditorElement = HTMLElement & { + editor?: Editor +} + +test('collapses a selection beside a document link without rewriting the link', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-links', + 'selection', + testInfo.workerIndex, + '[[Guide]]after\n\nSelecting text beside a document link should keep the link intact.' + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await expect(editor.locator('[data-doc-link-target="Guide"]')).toHaveCount(1) + await orcaPage.evaluate(() => { + const editorElement = + document.querySelector('.rich-markdown-editor') + const instance = editorElement?.editor + if (!editorElement || !instance) { + throw new Error('Editor unavailable') + } + editorElement.focus() + if (!instance.commands.setTextSelection({ from: 2, to: 7 })) { + throw new Error('Selection unavailable') + } + }) + await expect.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())).toBe('after') + await expect + .poll(() => + orcaPage.evaluate(() => { + const selection = + document.querySelector('.rich-markdown-editor')?.editor + ?.state.selection + return selection ? { from: selection.from, to: selection.to, empty: selection.empty } : null + }) + ) + .toEqual({ from: 2, to: 7, empty: false }) + await expect(editor).toBeFocused() + await orcaPage.keyboard.press('ArrowLeft') + await expect(editor.locator('[data-doc-link-target="Guide"]')).toHaveCount(1) + await expect(editor.locator('p').first()).toHaveText('Guideafter') + await expect.poll(() => orcaPage.evaluate(() => window.getSelection()?.toString())).toBe('') + await orcaPage.screenshot({ path: testInfo.outputPath('link-after-collapse.png') }) +}) diff --git a/tests/e2e/markdown-find-adversarial.spec.ts b/tests/e2e/markdown-find-adversarial.spec.ts new file mode 100644 index 00000000000..99ebe2282cc --- /dev/null +++ b/tests/e2e/markdown-find-adversarial.spec.ts @@ -0,0 +1,307 @@ +import type { Locator, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { pressShortcut } from './helpers/shortcuts' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' + +const FIRST = 'First alpha paragraph for pointer selection.' +const SECOND = 'Second beta paragraph for pointer selection.' +const SOURCE = [ + '# Adversarial Find interactions', + 'Needle first match.', + ...Array.from({ length: 65 }, (_, index) => `Spacer ${index} keeps the search match away.`), + FIRST, + SECOND, + '| Left | Right |\n| --- | --- |\n| Cell alpha | Cell beta |\n| Cell gamma | Cell delta |', + '- [ ] Task marker', + '
\nDetails marker\n\nDetails body marker\n\n
', + '```javascript\nconst codeMarker = "clean";\n```', + 'Late target paragraph for a pending query.', + ...Array.from({ length: 30 }, (_, index) => `Tail spacer ${index} keeps the second match away.`), + 'Needle final match.' +].join('\n\n') + +async function textPoint(target: Locator, offset: number) { + return target.evaluate((element, offset) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let remaining = offset + let node = walker.nextNode() + while (node) { + if (node instanceof Text && remaining <= node.length) { + const range = document.createRange() + range.setStart(node, remaining) + range.collapse(true) + const bounds = range.getBoundingClientRect() + return { x: bounds.left, y: bounds.top + bounds.height / 2 } + } + remaining -= node.textContent?.length ?? 0 + node = walker.nextNode() + } + throw new Error('Text offset was not found') + }, offset) +} + +async function selectedText(page: Page) { + return page.evaluate(() => window.getSelection()?.toString() ?? '') +} + +async function centered(target: Locator) { + await target.evaluate((element) => element.scrollIntoView({ block: 'center' })) +} + +async function find(page: Page, query = 'Needle', status = '1/2') { + await pressShortcut(page, 'f') + const input = page.getByRole('textbox', { name: 'Find in rich markdown editor' }) + await expect(input).toBeFocused() + await input.fill(query) + await expect(page.locator('.rich-markdown-search-status')).toHaveText(status) + return input +} + +async function drag(page: Page, start: Locator, end: Locator, from: number, to: number) { + const firstPoint = await textPoint(start, from) + const lastPoint = await textPoint(end, to) + await page.mouse.move(firstPoint.x, firstPoint.y) + await page.mouse.down() + await page.mouse.move(lastPoint.x, lastPoint.y, { steps: 15 }) + await page.mouse.up() +} + +test.beforeEach(async ({ orcaPage, registerPostElectronShutdownCleanup }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-adversarial', + 'find-interactions', + testInfo.workerIndex, + SOURCE + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await editor.locator('p').first().click() +}) + +test('Find preserves Shift-click, double-click, and multi-paragraph copy selection', async ({ + orcaPage +}, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + const first = editor.getByText(FIRST, { exact: true }) + const second = editor.getByText(SECOND, { exact: true }) + const search = await find(orcaPage) + await centered(first) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + const scroll = await viewport.evaluate((element) => element.scrollTop) + const start = await textPoint(first, 6) + const end = await textPoint(second, 11) + await orcaPage.mouse.click(start.x, start.y) + await orcaPage.keyboard.down('Shift') + await orcaPage.mouse.click(end.x, end.y) + await orcaPage.keyboard.up('Shift') + expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`) + await expect(editor).toBeFocused() + await expect( + orcaPage + .locator('[data-tab-id]') + .filter({ hasText: 'find-interactions' }) + .last() + .locator('span.rounded-full') + ).toHaveCount(0) + await orcaPage.mouse.click(start.x, start.y) + await search.focus() + await orcaPage.keyboard.down('Shift') + await orcaPage.mouse.click(end.x, end.y) + await orcaPage.keyboard.up('Shift') + expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`) + const copied = await editor.evaluate((element) => { + const clipboard = new DataTransfer() + element.dispatchEvent( + new ClipboardEvent('copy', { bubbles: true, cancelable: true, clipboardData: clipboard }) + ) + return { plain: clipboard.getData('text/plain'), html: clipboard.getData('text/html') } + }) + expect(copied.plain).toContain(FIRST.slice(6)) + expect(copied.plain).toContain(SECOND.slice(0, 11)) + expect(copied.html).toContain('

') + await expect(editor.getByText('Needle first match.', { exact: true })).toBeVisible() + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0) + await orcaPage.screenshot({ path: testInfo.outputPath('shift-click-copy-selection.png') }) + await search.focus() + const word = await textPoint(first, 9) + await orcaPage.mouse.dblclick(word.x, word.y) + expect(await selectedText(orcaPage)).toBe('alpha') + await orcaPage.keyboard.type('xy', { delay: 100 }) + await expect( + editor.getByText('First xy paragraph for pointer selection.', { exact: true }) + ).toBeVisible() + await expect(search).toHaveValue('Needle') + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2') +}) + +test('Replace input returns to a multi-paragraph drag and explicit next-match navigation', async ({ + orcaPage +}, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + const search = await find(orcaPage) + await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click() + const replace = orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' }) + await replace.fill('Thread') + const first = editor.getByText(FIRST, { exact: true }) + const second = editor.getByText(SECOND, { exact: true }) + await centered(first) + await drag(orcaPage, first, second, 6, 11) + expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`) + await orcaPage.keyboard.type('xy', { delay: 100 }) + await expect( + editor.getByText(`${FIRST.slice(0, 6)}xy${SECOND.slice(11)}`, { exact: true }) + ).toBeVisible() + await orcaPage.getByRole('button', { name: 'Next match', exact: true }).click() + await expect.poll(() => selectedText(orcaPage)).toBe('Needle') + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2') + await replace.focus() + await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click() + await expect(editor.getByText('Thread final match.', { exact: true })).toBeVisible() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1') + await expect(replace).toBeFocused() + await orcaPage.keyboard.press('Escape') + await expect(search).toHaveCount(0) + await pressShortcut(orcaPage, 'f') + await expect(search).toBeFocused() + await expect(search).toHaveValue('') + await orcaPage.screenshot({ path: testInfo.outputPath('replace-next-reopen.png') }) +}) + +test('Find returns focus and copies the intended cells after Shift-clicking a table', async ({ + orcaPage +}, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + const search = await find(orcaPage, 'Cell alpha', '1/1') + const firstCell = editor.getByText('Cell alpha', { exact: true }) + const lastCell = editor.getByText('Cell delta', { exact: true }) + await centered(firstCell) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + const scroll = await viewport.evaluate((element) => element.scrollTop) + const point = await textPoint(lastCell, 6) + await orcaPage.keyboard.down('Shift') + await orcaPage.mouse.move(point.x, point.y) + await orcaPage.mouse.down() + await orcaPage.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + ) + await orcaPage.mouse.up() + await orcaPage.keyboard.up('Shift') + await expect(editor.locator('td.selectedCell')).toHaveCount(4) + await orcaPage.screenshot({ path: testInfo.outputPath('shift-cell-selection.png') }) + await expect(editor).toBeFocused() + await expect(search).toHaveValue('Cell alpha') + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0) + const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'find-interactions' }).last() + await expect(tab.locator('span.rounded-full')).toHaveCount(0) + const copied = await editor.evaluate((element) => { + const clipboard = new DataTransfer() + element.dispatchEvent( + new ClipboardEvent('copy', { + bubbles: true, + cancelable: true, + clipboardData: clipboard + }) + ) + return clipboard.getData('text/plain') + }) + expect(copied).toBe('Cell alpha\n\nCell beta\n\nCell gamma\n\nCell delta') +}) + +test('Find preserves embedded task, details, and code editing', async ({ orcaPage }, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + const search = await find(orcaPage) + await centered(editor.getByRole('checkbox')) + await search.focus() + const checkbox = editor.getByRole('checkbox') + await checkbox.check() + await expect(checkbox).toBeChecked() + await expect(search).toHaveValue('Needle') + await search.focus() + const details = editor.locator('[data-type="details"]') + await details.getByRole('button').click() + await expect(details.locator('[data-type="detailsContent"]')).toBeHidden() + await search.focus() + await details.getByRole('button').click() + await expect(details.locator('[data-type="detailsContent"]')).toBeVisible() + const body = editor.getByText('Details body marker', { exact: true }) + const bodyPoint = await textPoint(body, 7) + await search.focus() + await orcaPage.mouse.click(bodyPoint.x, bodyPoint.y) + await orcaPage.keyboard.type('xy', { delay: 100 }) + await expect(editor.getByText('Detailsxy body marker', { exact: true })).toBeVisible() + const code = editor.getByText('const codeMarker = "clean";', { exact: true }) + await centered(code) + const codePoint = await textPoint(code, 6) + await search.focus() + await orcaPage.mouse.click(codePoint.x, codePoint.y) + await orcaPage.keyboard.type('xy', { delay: 100 }) + await expect(editor.getByText('const xycodeMarker = "clean";', { exact: true })).toBeVisible() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2') + await orcaPage.screenshot({ path: testInfo.outputPath('embedded-control-editing.png') }) +}) + +test('a pending Find query cannot claim focus after document and checkbox interaction', async ({ + orcaPage +}, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + const search = await find(orcaPage) + const target = editor.getByText('Late target paragraph for a pending query.', { exact: true }) + await centered(target) + const point = await textPoint(target, 5) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + const scroll = await viewport.evaluate((element) => element.scrollTop) + const checkbox = editor.getByRole('checkbox') + const checkboxPoint = await checkbox.evaluate((element) => { + const bounds = element.getBoundingClientRect() + return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 } + }) + await search.fill('Cell alpha') + await orcaPage.mouse.click(point.x, point.y) + await expect(editor).toBeFocused() + await orcaPage.mouse.click(checkboxPoint.x, checkboxPoint.y) + await expect(checkbox).toBeChecked() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1') + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0) + await expect(target).toHaveText('Late target paragraph for a pending query.') + await orcaPage.mouse.click(point.x, point.y) + await orcaPage.keyboard.type('xy', { delay: 100 }) + await expect( + editor.getByText('Late xytarget paragraph for a pending query.', { exact: true }) + ).toBeVisible() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1') + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0) + await expect(search).toHaveValue('Cell alpha') + await orcaPage.screenshot({ path: testInfo.outputPath('pending-query-editor-caret.png') }) +}) + +test('Replace advances when its replacement still contains the search query', async ({ + orcaPage +}, testInfo) => { + const editor = orcaPage.locator('.rich-markdown-editor') + await find(orcaPage) + await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click() + await orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' }).fill('NeedleX') + await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click() + await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2') + await expect( + editor.getByText('Needle final match.', { exact: true }).locator('[data-active="true"]') + ).toHaveText('Needle') + await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click() + await expect(editor.getByText('NeedleX final match.', { exact: true })).toBeVisible() + await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2') + await orcaPage.screenshot({ path: testInfo.outputPath('replacement-retains-query.png') }) +}) diff --git a/tests/e2e/markdown-find-editing.spec.ts b/tests/e2e/markdown-find-editing.spec.ts new file mode 100644 index 00000000000..b227ba81390 --- /dev/null +++ b/tests/e2e/markdown-find-editing.spec.ts @@ -0,0 +1,215 @@ +import type { Locator, Page } from '@stablyai/playwright-test' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { pressShortcut } from './helpers/shortcuts' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' + +const TARGET_TEXT = 'Editing target paragraph keeps the pointer position and viewport.' +const MARKDOWN = [ + '# Find and pointer editing', + 'Needle remains at the beginning.', + ...Array.from({ length: 100 }, (_, index) => + index === 70 ? TARGET_TEXT : `Paragraph ${index} provides room to scroll through the document.` + ), + 'Needle remains at the end.' +].join('\n\n') +const TABLE_MARKDOWN = MARKDOWN.replace( + TARGET_TEXT, + `| Name | Value |\n| --- | --- |\n| Target | ${TARGET_TEXT} |` +) + +async function textPoint(paragraph: Locator, offset: number) { + return paragraph.evaluate((element, offset) => { + const text = element.firstChild + if (!(text instanceof Text)) { + throw new Error('Expected a plain-text paragraph') + } + const range = document.createRange() + range.setStart(text, offset) + range.collapse(true) + const bounds = range.getBoundingClientRect() + return { x: bounds.left, y: bounds.top + bounds.height / 2 } + }, offset) +} + +async function readParagraphSelection(paragraph: Locator) { + return paragraph.evaluate((element) => { + const selection = window.getSelection() + if (!selection?.anchorNode || !selection.focusNode) { + return null + } + if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) { + return null + } + const start = document.createRange() + start.selectNodeContents(element) + start.setEnd(selection.anchorNode, selection.anchorOffset) + const end = document.createRange() + end.selectNodeContents(element) + end.setEnd(selection.focusNode, selection.focusOffset) + return { + from: Math.min(start.toString().length, end.toString().length), + to: Math.max(start.toString().length, end.toString().length), + text: selection.toString() + } + }) +} + +async function centerParagraph(paragraph: Locator): Promise { + await paragraph.evaluate((element) => element.scrollIntoView({ block: 'center' })) +} + +async function pointAtParagraph(page: Page, paragraph: Locator, drag: boolean) { + const start = await textPoint(paragraph, 8) + await page.mouse.move(start.x, start.y) + await page.mouse.down() + if (drag) { + const end = await textPoint(paragraph, 24) + await page.mouse.move(end.x, end.y, { steps: 12 }) + } + // Tiptap focus commands can schedule a stale-selection scroll for the next frame. + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + ) + await page.mouse.up() + const selection = await readParagraphSelection(paragraph) + expect(selection).not.toBeNull() + if (!selection) { + throw new Error('Pointer selection left the intended paragraph') + } + if (drag) { + expect(selection.text).toBe('target paragraph') + } else { + expect(selection.from).toBe(selection.to) + } + return selection +} + +test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) +}) + +for (const interaction of ['click', 'drag'] as const) { + test(`keeps Find open while a document ${interaction} edits at the pointer position`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-find-editing', + `find-${interaction}`, + testInfo.workerIndex, + MARKDOWN + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + const paragraph = editor.locator('p').filter({ hasText: 'Editing' }) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + await editor.locator('p').first().click() + await pressShortcut(orcaPage, 'f') + const search = orcaPage.getByRole('textbox', { name: 'Find in rich markdown editor' }) + await expect(search).toBeFocused() + await search.fill('Needle') + await orcaPage.getByRole('button', { name: 'Match case', exact: true }).click() + await orcaPage.getByRole('button', { name: 'Match whole word', exact: true }).click() + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2') + await centerParagraph(paragraph) + const originalScroll = await viewport.evaluate((element) => element.scrollTop) + const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag') + + // Separate key events allow document updates to expose a caret reset between characters. + await orcaPage.keyboard.type('xy', { delay: 100 }) + await orcaPage.screenshot({ path: testInfo.outputPath('find-document-edit.png') }) + await expect(paragraph).toHaveText( + `${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}` + ) + await expect(editor.locator('p').first()).toHaveText('Needle remains at the beginning.') + await expect + .poll(() => readParagraphSelection(paragraph)) + .toEqual({ + from: selection.from + 2, + to: selection.from + 2, + text: '' + }) + await expect + .poll(() => viewport.evaluate((element) => element.scrollTop)) + .toBeCloseTo(originalScroll, 0) + await expect(search).toBeVisible() + await expect(search).toHaveValue('Needle') + await expect(orcaPage.getByRole('button', { name: 'Match case', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + await expect( + orcaPage.getByRole('button', { name: 'Match whole word', exact: true }) + ).toHaveAttribute('aria-pressed', 'true') + await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2') + }) + + test(`keeps the scrolled table and ${interaction} selection after switching Source to Rich`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-find-editing', + `source-${interaction}`, + testInfo.workerIndex, + TABLE_MARKDOWN + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + await waitForRichMarkdownEditor(orcaPage) + await orcaPage.getByRole('radio', { name: 'Source', exact: true }).click() + await expect(orcaPage.locator('.monaco-editor')).toBeVisible() + await orcaPage.getByRole('radio', { name: 'Rich Editor', exact: true }).click() + const editor = await waitForRichMarkdownEditor(orcaPage) + await expect(editor).not.toBeFocused() + const paragraph = editor.locator('td p').filter({ hasText: 'Editing' }) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + await centerParagraph(paragraph) + const originalScroll = await viewport.evaluate((element) => element.scrollTop) + const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag') + await expect(editor).toBeFocused() + await expect(paragraph).toHaveText(TARGET_TEXT) + await expect + .poll(() => viewport.evaluate((element) => element.scrollTop)) + .toBeCloseTo(originalScroll, 0) + const tab = orcaPage + .locator('[data-tab-id]') + .filter({ hasText: path.basename(filePath) }) + .last() + await expect(tab.locator('span.rounded-full')).toHaveCount(0) + await expect(tab.getByRole('button', { name: 'Close tab' })).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-table-selection.png') }) + await orcaPage.keyboard.type('xy', { delay: 100 }) + await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-pointer-edit.png') }) + await expect(paragraph).toHaveText( + `${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}` + ) + await expect + .poll(() => readParagraphSelection(paragraph)) + .toEqual({ + from: selection.from + 2, + to: selection.from + 2, + text: '' + }) + await expect + .poll(() => viewport.evaluate((element) => element.scrollTop)) + .toBeCloseTo(originalScroll, 0) + }) +} diff --git a/tests/e2e/markdown-review-selection-performance.spec.ts b/tests/e2e/markdown-review-selection-performance.spec.ts new file mode 100644 index 00000000000..a9ee0035599 --- /dev/null +++ b/tests/e2e/markdown-review-selection-performance.spec.ts @@ -0,0 +1,209 @@ +import type { Locator, Page } from '@stablyai/playwright-test' +import type { Editor, JSONContent } from '@tiptap/core' +import type { MarkdownManager } from '@tiptap/markdown' +import { expect, test } from './helpers/orca-app' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' + +const PARAGRAPH_COUNT = Number(process.env.ORCA_MARKDOWN_REVIEW_PERF_PARAGRAPHS ?? '500') +const targetIndex = Math.floor(PARAGRAPH_COUNT / 2) +const paragraph = (index: number) => + `Paragraph ${index}. Ordinary editing and selection of plain prose.` +const SOURCE = Array.from({ length: PARAGRAPH_COUNT }, (_, index) => paragraph(index)).join('\n\n') + +type ReviewSelectionMetrics = { + calls: number + jsonCalls: number + serializeMs: number + jsonMs: number + frameGaps: number[] +} + +type ReviewSelectionProbe = { + snapshot: () => ReviewSelectionMetrics + reset: () => void + restore: () => void +} + +type PageRichMarkdownReviewEditorElement = HTMLElement & { + editor?: Editor & { markdown?: MarkdownManager } + __reviewSelectionProbe?: ReviewSelectionProbe +} + +async function frames(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) +} + +async function measure(editor: Locator, reset = false): Promise { + return editor.evaluate((element, reset) => { + const editorElement = + element.closest('.rich-markdown-editor') + const probe = editorElement?.__reviewSelectionProbe + if (!probe) { + throw new Error('Review selection probe missing') + } + if (reset) { + probe.reset() + } + return probe.snapshot() + }, reset) +} + +test('selection and scrolling reuse Markdown review source lines', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + test.setTimeout(180_000) + expect(Buffer.byteLength(SOURCE)).toBeLessThan(600 * 1024) + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-review-perf', + 'review-selection', + testInfo.workerIndex, + SOURCE + ) + registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath)) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await expect(editor.locator('p')).toHaveCount(PARAGRAPH_COUNT, { timeout: 60_000 }) + await editor.evaluate((element) => { + const editorElement = + element.closest('.rich-markdown-editor') + const instance = editorElement?.editor + if (!editorElement || !instance) { + throw new Error('Editor unavailable') + } + const markdown = instance.markdown + if (!markdown) { + throw new Error('Markdown manager missing') + } + const serialize = markdown.serialize + const getJSON = instance.getJSON + let calls = 0 + let jsonCalls = 0 + let serializeMs = 0 + let jsonMs = 0 + let frameGaps: number[] = [] + let previousFrame: number | null = null + let frameId = 0 + const tick = (now: number) => { + if (previousFrame !== null) { + frameGaps.push(now - previousFrame) + } + previousFrame = now + frameId = requestAnimationFrame(tick) + } + frameId = requestAnimationFrame(tick) + markdown.serialize = (content: JSONContent): string => { + const start = performance.now() + try { + calls++ + return serialize.call(markdown, content) + } finally { + serializeMs += performance.now() - start + } + } + instance.getJSON = (): ReturnType => { + const start = performance.now() + try { + jsonCalls++ + return getJSON.call(instance) + } finally { + jsonMs += performance.now() - start + } + } + editorElement.__reviewSelectionProbe = { + snapshot: () => ({ calls, jsonCalls, serializeMs, jsonMs, frameGaps }), + reset: () => { + calls = 0 + jsonCalls = 0 + serializeMs = 0 + jsonMs = 0 + frameGaps = [] + previousFrame = null + }, + restore: () => { + cancelAnimationFrame(frameId) + markdown.serialize = serialize + instance.getJSON = getJSON + } + } + }) + try { + const target = editor.getByText(paragraph(targetIndex), { exact: true }) + await target.evaluate((element) => element.scrollIntoView({ block: 'center' })) + const point = await target.evaluate((element) => { + const text = element.firstChild + if (!(text instanceof Text)) { + throw new Error('Paragraph text missing') + } + const range = document.createRange() + range.setStart(text, 0) + range.collapse(true) + const rect = range.getBoundingClientRect() + return { x: rect.left, y: rect.top + rect.height / 2 } + }) + await orcaPage.mouse.click(point.x, point.y) + await orcaPage.keyboard.press('Shift+ArrowRight') + await expect( + orcaPage.getByRole('button', { name: 'Add review note', exact: true }) + ).toBeVisible() + await frames(orcaPage) + const cold = await measure(editor) + await measure(editor, true) + for (let index = 0; index < 20; index++) { + await orcaPage.keyboard.press('Shift+ArrowRight') + await frames(orcaPage) + } + const selection = await measure(editor) + expect(await orcaPage.evaluate(() => window.getSelection()?.toString())).toBe( + paragraph(targetIndex).slice(0, 21) + ) + const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto') + await measure(editor, true) + for (let index = 0; index < 10; index++) { + await viewport.evaluate((element, index) => { + element.scrollTop += index % 2 ? -8 : 8 + }, index) + await frames(orcaPage) + } + const scroll = await measure(editor) + const result = { + paragraphs: PARAGRAPH_COUNT, + sourceBytes: Buffer.byteLength(SOURCE), + cold, + selection, + scroll + } + await testInfo.attach('review-selection-metrics', { + body: JSON.stringify(result, null, 2), + contentType: 'application/json' + }) + process.stdout.write(`${JSON.stringify(result)}\n`) + await expect(editor).toBeFocused() + const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'review-selection' }).last() + await expect(tab.locator('span.rounded-full')).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('review-selection.png') }) + expect(selection.calls).toBe(0) + expect(selection.jsonCalls).toBe(0) + expect(scroll.calls).toBe(0) + expect(scroll.jsonCalls).toBe(0) + } finally { + await editor.evaluate((element) => { + const editorElement = + element.closest('.rich-markdown-editor') + editorElement?.__reviewSelectionProbe?.restore() + }) + } +}) diff --git a/tests/e2e/notebook-cell-external-sync.spec.ts b/tests/e2e/notebook-cell-external-sync.spec.ts new file mode 100644 index 00000000000..7eb788e82ad --- /dev/null +++ b/tests/e2e/notebook-cell-external-sync.spec.ts @@ -0,0 +1,106 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { getActiveWorktreeContext } from './helpers/markdown-editor-fixture' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { parseIpynb } from '../../src/renderer/src/components/editor/ipynb-parse' + +const FIRST = { id: 'first', source: 'print("original")' } +const SECOND = { id: 'second', source: 'print("other")' } + +function notebookContent(cells: { id: string; source: string }[]): string { + return JSON.stringify({ + nbformat: 4, + nbformat_minor: 5, + metadata: { language_info: { name: 'python' } }, + cells: cells.map(({ id, source }) => ({ + id, + cell_type: 'code', + metadata: {}, + execution_count: null, + outputs: [], + source: [source] + })) + }) +} + +async function openNotebook(page: Page, filePath: string): Promise { + const context = await getActiveWorktreeContext(page) + await page.evaluate( + ({ filePath, worktreeId, relativePath }) => { + window.__store?.getState().openFile({ + filePath, + relativePath, + worktreeId, + language: 'json', + mode: 'edit' + }) + }, + { + filePath, + worktreeId: context.worktreeId, + relativePath: path.relative(context.rootPath, filePath) + } + ) + await expect(page.locator('.ipynb-code-surface')).toHaveCount(2) +} + +async function fileIsDirty(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + return state?.openFiles.find((file) => file.id === state.activeFileId)?.isDirty ?? null + }) +} + +test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) +}) + +for (const reload of ['source', 'reorder'] as const) { + test(`keeps the active cell current after an external notebook ${reload} reload`, async ({ + orcaPage, + registerPostElectronShutdownCleanup + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + const directory = path.join(context.rootPath, '.orca-e2e-notebook-external-sync') + await mkdir(directory, { recursive: true }) + const filePath = path.join(directory, `${reload}-${testInfo.workerIndex}-${randomUUID()}.ipynb`) + registerPostElectronShutdownCleanup(() => rm(filePath, { force: true })) + await writeFile(filePath, notebookContent([FIRST, SECOND]), 'utf8') + await openNotebook(orcaPage, filePath) + const surfaces = orcaPage.locator('.ipynb-code-surface') + await surfaces.first().getByRole('button').click() + await expect(orcaPage.locator('.ipynb-code-surface .monaco-editor')).toBeVisible() + await expect.poll(() => fileIsDirty(orcaPage)).toBe(false) + + const updatedFirst = reload === 'source' ? { ...FIRST, source: 'print("updated")' } : FIRST + const updatedSecond = { ...SECOND, source: 'print("reload marker")' } + const externalCells = + reload === 'source' ? [updatedFirst, updatedSecond] : [updatedSecond, updatedFirst] + await writeFile(filePath, notebookContent(externalCells), 'utf8') + const preview = surfaces.nth(reload === 'source' ? 1 : 0) + await expect(preview).toHaveText(updatedSecond.source, { timeout: 25_000 }) + await expect.poll(() => fileIsDirty(orcaPage)).toBe(false) + await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-clean.png') }) + const activeCell = surfaces.nth(reload === 'source' ? 0 : 1) + await expect.soft(activeCell.locator('.view-lines')).toHaveText(updatedFirst.source) + + await orcaPage.keyboard.press('End') + await orcaPage.keyboard.type('!', { delay: 100 }) + await expect.poll(() => fileIsDirty(orcaPage)).toBe(true) + await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-cell-edit.png') }) + await orcaPage.getByRole('button', { name: 'Save notebook', exact: true }).click() + const editedFirst = { ...updatedFirst, source: `${updatedFirst.source}!` } + await expect + .poll(async () => { + const saved = parseIpynb(await readFile(filePath, 'utf8')) + return saved.cells.map(({ id, source }) => ({ id, source })) + }) + .toEqual(reload === 'source' ? [editedFirst, updatedSecond] : [updatedSecond, editedFirst]) + await expect.poll(() => fileIsDirty(orcaPage)).toBe(false) + await orcaPage.screenshot({ path: testInfo.outputPath('external-reload-cell-saved.png') }) + }) +} diff --git a/tests/e2e/worktree-lineage-scroll-readiness.ts b/tests/e2e/worktree-lineage-scroll-readiness.ts new file mode 100644 index 00000000000..75d38bbf161 --- /dev/null +++ b/tests/e2e/worktree-lineage-scroll-readiness.ts @@ -0,0 +1,142 @@ +import type { Page } from '@stablyai/playwright-test' + +// Freeze the oracle only after the fixture's reveal and measured rows have settled. +export async function waitForLineageScrollFixtureReady( + page: Page, + parentId: string +): Promise { + await page.evaluate( + (parentId) => + new Promise((resolve, reject) => { + const sidebar = document.querySelector('[data-worktree-sidebar]') + if (!sidebar) { + reject(new Error('Missing sidebar')) + return + } + const quietSpanMs = 500 + const started = performance.now() + let lastScrollAt = started + let unchangedSince = started + let signature = '' + let frameId: number | null = null + let frames = 0 + let intervalId: ReturnType | undefined + let timeoutId: ReturnType | undefined + const cleanup = (): void => { + sidebar.removeEventListener('scroll', onScroll) + window.removeEventListener('pagehide', onPageHide) + clearInterval(intervalId) + clearTimeout(timeoutId) + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + const finish = (error?: Error): void => { + cleanup() + if (error) { + reject(error) + } else { + resolve() + } + } + const onScroll = (): void => { + lastScrollAt = performance.now() + } + const onPageHide = (): void => finish(new Error('Renderer closed before fixture settled')) + const readSignature = (): string => { + const parent = sidebar.querySelector( + `[role="option"][data-worktree-id=${JSON.stringify(parentId)}]` + ) + const wrapper = parent?.closest('[data-worktree-virtual-row]') + if ( + !parent?.isConnected || + !wrapper || + document.fonts.status !== 'loaded' || + sidebar.scrollTop <= 0 + ) { + return '' + } + const mounted = Array.from( + sidebar.querySelectorAll('[data-worktree-virtual-row]') + ) + if (mounted.length > 100) { + throw new Error('Unbounded lineage fixture row measurement') + } + const parentRect = parent.getBoundingClientRect() + const sidebarRect = sidebar.getBoundingClientRect() + return JSON.stringify({ + id: parent.dataset.worktreeId, + key: wrapper.dataset.worktreeVirtualRowKey, + index: wrapper.dataset.index, + start: wrapper.dataset.worktreeVirtualRowStart, + transform: wrapper.style.transform, + parentTop: parentRect.top, + parentHeight: parentRect.height, + scrollTop: sidebar.scrollTop, + scrollHeight: sidebar.scrollHeight, + sidebarTop: sidebarRect.top, + sidebarHeight: sidebarRect.height, + canvasHeight: wrapper.parentElement?.style.height, + rows: mounted.map((row) => ({ + key: row.dataset.worktreeVirtualRowKey, + index: row.dataset.index, + start: row.dataset.worktreeVirtualRowStart, + transform: row.style.transform, + height: row.getBoundingClientRect().height + })) + }) + } + const inspect = (): boolean => { + const next = readSignature() + if (!next || next !== signature) { + signature = next + unchangedSince = performance.now() + frames = 0 + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + frameId = null + return false + } + const now = performance.now() + const ready = now - unchangedSince >= quietSpanMs && now - lastScrollAt >= quietSpanMs + if (!ready) { + frames = 0 + } + return ready + } + const onFrame = (): void => { + frameId = null + try { + if (!inspect()) { + return + } + frames += 1 + if (frames === 2) { + finish() + return + } + frameId = requestAnimationFrame(onFrame) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + } + sidebar.addEventListener('scroll', onScroll, { passive: true }) + window.addEventListener('pagehide', onPageHide, { once: true }) + intervalId = setInterval(() => { + try { + if (inspect() && frameId === null) { + frameId = requestAnimationFrame(onFrame) + } + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }, 50) + timeoutId = setTimeout( + () => finish(new Error(`Lineage fixture did not settle: ${signature}`)), + 10000 + ) + }), + parentId + ) +} diff --git a/tests/e2e/worktree-lineage-state.ts b/tests/e2e/worktree-lineage-state.ts index 58a84cfe2d8..219bb07c27e 100644 --- a/tests/e2e/worktree-lineage-state.ts +++ b/tests/e2e/worktree-lineage-state.ts @@ -7,9 +7,9 @@ export type LineageScenario = { export async function seedLineageScenario( page: Page, - options: { inlineOnly?: boolean } = {} + options: { inlineOnly?: boolean; preserveGrouping?: boolean } = {} ): Promise { - return page.evaluate(({ inlineOnly }) => { + return page.evaluate(({ inlineOnly, preserveGrouping }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -18,7 +18,9 @@ export async function seedLineageScenario( const state = store.getState() state.setActiveView('terminal') state.setSidebarOpen(true) - state.setGroupBy('none') + if (!preserveGrouping) { + state.setGroupBy('none') + } state.setSortBy('recent') // Why: these specs assert lineage structure, not the user's persisted // sidebar filters. Make the seeded child render even when it has no live PTY. diff --git a/tests/e2e/worktree-lineage-toggle-dialog.spec.ts b/tests/e2e/worktree-lineage-toggle-dialog.spec.ts new file mode 100644 index 00000000000..9edb3a7182b --- /dev/null +++ b/tests/e2e/worktree-lineage-toggle-dialog.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' +import { seedLineageScenario } from './worktree-lineage-state' + +test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) + +test('locally controlled project-group modal keeps child-toggle input', async ({ + orcaPage, + electronApp +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + const family = await seedLineageScenario(orcaPage) + const groupId = await orcaPage.evaluate(async ({ parentId }) => { + const store = window.__store + if (!store) { + throw new Error('Missing store') + } + const parent = Object.values(store.getState().worktreesByRepo) + .flat() + .find((row) => row.id === parentId) + if (!parent) { + throw new Error('Missing parent') + } + const group = await store.getState().createProjectGroup('Shortcut dialog group') + if (!group) { + throw new Error('Missing project group') + } + await store.getState().moveProjectToGroup(parent.repoId, group.id) + store.getState().setGroupBy('repo') + await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H']) + window.addEventListener('keydown', (event) => { + if (event.code === 'KeyH') { + document.body.dataset.childToggleClaimed = String(event.defaultPrevented) + } + }) + return group.id + }, family) + const group = orcaPage.locator(`[data-project-group-header-id="${groupId}"]`) + await expect( + orcaPage + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(family.parentId)}]` + ) + .first() + .getByRole('button', { name: 'Hide 1 child workspace' }) + ).toBeVisible() + await group.hover() + await group.getByRole('button', { name: /Group actions/i }).click() + // The menu is not a modal input owner. + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + expect(await orcaPage.evaluate(() => window.__store?.getState().collapsedGroups.size)).toBe(1) + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + expect(await orcaPage.evaluate(() => window.__store?.getState().collapsedGroups.size)).toBe(0) + await orcaPage.getByRole('menuitem', { name: /Delete/i }).click() + const dialog = orcaPage.getByRole('dialog', { name: 'Delete Project Group' }) + await expect(dialog).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Delete Group', exact: true })).toBeFocused() + expect(await orcaPage.evaluate(() => window.__store?.getState().activeModal)).toBe('none') + const before = await orcaPage.evaluate(() => + [...(window.__store?.getState().collapsedGroups ?? [])].sort() + ) + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + const proof = testInfo.outputPath('local-modal-guard.png') + await orcaPage.screenshot({ path: proof }) + await testInfo.attach('local-modal-guard', { path: proof, contentType: 'image/png' }) + expect( + await orcaPage.evaluate(() => [...(window.__store?.getState().collapsedGroups ?? [])].sort()) + ).toEqual(before) + await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false') + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click() + await expect( + orcaPage + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(family.childId)}]` + ) + .first() + ).toBeVisible() +}) diff --git a/tests/e2e/worktree-lineage-toggle-scroll.spec.ts b/tests/e2e/worktree-lineage-toggle-scroll.spec.ts new file mode 100644 index 00000000000..0e7b078f1a1 --- /dev/null +++ b/tests/e2e/worktree-lineage-toggle-scroll.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' +import { seedLineageScenario } from './worktree-lineage-state' +import { waitForLineageScrollFixtureReady } from './worktree-lineage-scroll-readiness' + +test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) + +test('keyboard and chip preserve the scrolled parent, and unfolding opens the sidebar', async ({ + orcaPage, + electronApp +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) + await orcaPage.setViewportSize({ width: 1200, height: 800 }) + const family = await seedLineageScenario(orcaPage) + await orcaPage.evaluate(async ({ parentId, childId }) => { + const store = window.__store + if (!store) { + throw new Error('Missing store') + } + const state = store.getState() + const parent = Object.values(state.worktreesByRepo) + .flat() + .find((row) => row.id === parentId) + if (!parent) { + throw new Error('Missing parent') + } + const surrounding = Array.from({ length: 40 }, (_, index) => ({ + ...parent, + id: `scroll-card-${index}`, + instanceId: `scroll-instance-${index}`, + displayName: `Surrounding workspace ${index}`, + isMainWorktree: false, + isPinned: false, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + sortOrder: 40 - index + })) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { + ...state.worktreesByRepo, + [parent.repoId]: [ + ...state.worktreesByRepo[parent.repoId].map((row) => ({ + ...row, + sortOrder: row.id === parentId ? 16.5 : row.id === childId ? 16.4 : row.sortOrder + })), + ...surrounding + ] + } + }) + await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H']) + }, family) + + const sidebar = orcaPage.locator('[data-worktree-sidebar]') + const parentRow = sidebar + .locator(`[role="option"][data-worktree-id=${JSON.stringify(family.parentId)}]`) + .first() + const childRow = sidebar + .locator(`[role="option"][data-worktree-id=${JSON.stringify(family.childId)}]`) + .first() + await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click() + await expect(parentRow).toBeVisible() + await parentRow.evaluate((row) => row.scrollIntoView({ block: 'center' })) + await expect.poll(() => sidebar.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + const geometry = () => + parentRow.evaluate((row) => ({ + top: row.getBoundingClientRect().top, + scrollTop: row.closest('[data-worktree-sidebar]')?.scrollTop ?? 0 + })) + await waitForLineageScrollFixtureReady(orcaPage, family.parentId) + const before = await geometry() + await orcaPage.mouse.move(1150, 400) + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + await expect(childRow).toBeHidden() + await expect.poll(async () => Math.abs((await geometry()).top - before.top)).toBeLessThan(2) + expect((await geometry()).scrollTop).toBeGreaterThan(0) + const collapsedProof = testInfo.outputPath('keyboard-collapsed-parent-anchor.png') + await sidebar.screenshot({ path: collapsedProof }) + await testInfo.attach('keyboard-collapsed-parent-anchor', { + path: collapsedProof, + contentType: 'image/png' + }) + + await parentRow.getByRole('button', { name: 'Show 1 child workspace' }).click() + await expect(childRow).toBeVisible() + await expect.poll(async () => Math.abs((await geometry()).top - before.top)).toBeLessThan(2) + await orcaPage.mouse.move(1150, 400) + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + await expect(childRow).toBeHidden() + await orcaPage.evaluate(() => window.__store?.getState().setSidebarOpen(false)) + await expect(sidebar).toBeHidden() + await orcaPage.keyboard.press('ControlOrMeta+Alt+KeyH') + await expect(sidebar).toBeVisible() + await expect(childRow).toBeVisible() +}) diff --git a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts new file mode 100644 index 00000000000..9d061af8518 --- /dev/null +++ b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts @@ -0,0 +1,485 @@ +import type { Page } from '@stablyai/playwright-test' +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { seedLineageScenario, seedWorkspaceLiveTerminal } from './worktree-lineage-state' + +test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) + +const ACTION_ID = 'sidebar.childWorkspaces.toggle' as const +const CHORD = 'ControlOrMeta+Alt+KeyH' + +function sidebarWorktreeRow(page: Page, worktreeId: string) { + return page + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(worktreeId)}]` + ) + .first() +} + +async function waitForLineageWorktrees(page: Page): Promise { + await expect + .poll( + () => + page.evaluate(async () => { + const store = window.__store + if (!store) { + return 0 + } + for (const repo of store.getState().repos) { + await store.getState().fetchWorktrees(repo.id) + } + return Object.values(store.getState().worktreesByRepo) + .flat() + .filter((worktree) => !worktree.isArchived).length + }), + { timeout: 30_000 } + ) + .toBeGreaterThanOrEqual(2) +} + +async function captureEvidence(page: Page, name: string): Promise { + if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') { + return + } + const directory = join(process.cwd(), 'pr-evidence') + mkdirSync(directory, { recursive: true }) + await page.screenshot({ path: join(directory, name) }) +} + +async function setToggleBinding(page: Page, bindings: string[] | null): Promise { + await page.evaluate( + async ({ actionId, bindings }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + await (bindings + ? state.setKeybindingOverride(actionId, bindings) + : state.resetKeybindingOverride(actionId)) + }, + { actionId: ACTION_ID, bindings } + ) +} + +async function movePointerOffSidebar(page: Page): Promise { + const viewport = + page.viewportSize() ?? + (await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight + }))) + await page.mouse.move(viewport.width - 20, viewport.height / 2) +} + +async function observeShortcutClaim(page: Page): Promise { + await page.evaluate(() => { + window.addEventListener('keydown', (event) => { + if (event.code === 'KeyH') { + document.body.dataset.childToggleClaimed = String(event.defaultPrevented) + } + }) + }) +} + +test.describe('Toggle Child Workspaces shortcut', () => { + test.beforeEach(async ({ orcaPage, electronApp }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) + }) + + test.afterEach(async ({ orcaPage }) => { + await setToggleBinding(orcaPage, null) + }) + + test('Settings exposes the unbound action and records an assigned shortcut', async ({ + orcaPage + }) => { + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ uiLanguage: 'en' }) + window.__store?.getState().openSettingsPage() + }) + await orcaPage.getByPlaceholder('Search settings').fill('shortcuts') + const search = orcaPage.getByPlaceholder('Search command or keys') + await expect(search).toBeVisible() + await search.fill('Toggle Child Workspaces') + await expect(orcaPage.getByText('Toggle Child Workspaces', { exact: true })).toBeVisible() + const add = orcaPage.getByRole('button', { name: 'Add shortcut for Toggle Child Workspaces' }) + await expect(add).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-settings-unbound.png') + + await add.click() + const recorder = orcaPage.locator('[data-shortcut-recorder-active]') + await expect(recorder).toBeVisible() + await recorder.press(CHORD) + await expect( + orcaPage.getByRole('button', { name: 'Change shortcut for Toggle Child Workspaces' }) + ).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-settings-assigned.png') + }) + + test('hides and shows children of the active or hovered workspace', async ({ orcaPage }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + const parentRow = sidebarWorktreeRow(orcaPage, parentId) + const childRow = sidebarWorktreeRow(orcaPage, childId) + + await parentRow.click() + await expect(parentRow).toHaveAttribute('aria-current', 'page') + await expect(childRow).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-sidebar-expanded.png') + + // No hovered card: the active parent is the target. + await movePointerOffSidebar(orcaPage) + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeHidden() + await expect(parentRow.getByRole('button', { name: 'Show 1 child workspace' })).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-sidebar-collapsed.png') + + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeVisible() + await expect(parentRow.getByRole('button', { name: 'Hide 1 child workspace' })).toBeVisible() + + await childRow.click() + await expect(childRow).toHaveAttribute('aria-current', 'page') + await movePointerOffSidebar(orcaPage) + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeHidden() + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeVisible() + await parentRow.click() + + // A hovered leaf child folds its parent. + await childRow.hover() + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeHidden() + await expect(parentRow.getByRole('button', { name: 'Show 1 child workspace' })).toBeVisible() + + // A hovered parent unfolds its own children. + await parentRow.hover() + await orcaPage.keyboard.press(CHORD) + await expect(childRow).toBeVisible() + }) + + test('a modal keeps its shortcut input while the parent chip is visible', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + await orcaPage.evaluate(() => window.__store?.getState().openModal('worktree-palette')) + await expect(orcaPage.getByRole('dialog')).toBeVisible() + await orcaPage.getByRole('dialog').evaluate((dialog) => { + dialog.setAttribute('tabindex', '-1') + dialog.focus() + }) + await orcaPage.keyboard.press(CHORD) + await expect( + sidebarWorktreeRow(orcaPage, parentId).getByRole('button', { + name: 'Hide 1 child workspace', + includeHidden: true + }) + ).toBeVisible() + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await orcaPage.keyboard.press('Escape') + }) + + test('a hovered parent takes precedence over another active family', async ({ orcaPage }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + const other = await orcaPage.evaluate( + ({ parentId, childId }) => { + const store = window.__store + if (!store) { + throw new Error('Missing store') + } + const state = store.getState() + const worktrees = Object.values(state.worktreesByRepo).flat() + const parent = worktrees.find((worktree) => worktree.id === parentId) + const child = worktrees.find((worktree) => worktree.id === childId) + const edge = state.worktreeLineageById[childId] + if (!parent || !child || !edge) { + throw new Error('Missing seeded family') + } + const otherParent = { + ...parent, + id: `${parentId}-other`, + instanceId: 'other-parent', + displayName: 'Another parent' + } + const otherChild = { + ...child, + id: `${childId}-other`, + instanceId: 'other-child', + displayName: 'Another child' + } + store.setState({ + worktreesByRepo: { + ...state.worktreesByRepo, + [parent.repoId]: [...state.worktreesByRepo[parent.repoId], otherParent, otherChild] + }, + worktreeLineageById: { + ...state.worktreeLineageById, + [otherChild.id]: { + ...edge, + worktreeId: otherChild.id, + worktreeInstanceId: otherChild.instanceId, + parentWorktreeId: otherParent.id, + parentWorktreeInstanceId: otherParent.instanceId + } + } + }) + return { parentId: otherParent.id, childId: otherChild.id } + }, + { parentId, childId } + ) + await sidebarWorktreeRow(orcaPage, other.parentId).hover() + await orcaPage.keyboard.press(CHORD) + await expect(sidebarWorktreeRow(orcaPage, other.childId)).toBeHidden() + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-hover-precedence.png') + }) + + test('a focused floating workspace keeps the shortcut input', async ({ orcaPage }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + await orcaPage.getByRole('button', { name: 'Show floating workspace', exact: true }).click() + const panel = orcaPage.locator('[data-floating-terminal-panel][aria-hidden="false"]') + await expect(panel).toBeVisible() + await panel.locator('[data-floating-terminal-shortcut-surface]').first().focus() + await orcaPage.keyboard.press(CHORD) + await expect( + sidebarWorktreeRow(orcaPage, parentId).getByRole('button', { name: 'Hide 1 child workspace' }) + ).toBeVisible() + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-floating-guard.png') + }) + + test('a stale child instance leaves no chip and does not claim the chord', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + await orcaPage.evaluate((childId) => { + window.__store?.setState((state) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [ + repoId, + rows.map((row) => + row.id === childId ? { ...row, instanceId: 'recreated-child' } : row + ) + ]) + ) + })) + }, childId) + const parentRow = sidebarWorktreeRow(orcaPage, parentId) + await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0) + await parentRow.click() + await orcaPage.locator('[data-worktree-sidebar]').focus() + await movePointerOffSidebar(orcaPage) + await observeShortcutClaim(orcaPage) + await orcaPage.keyboard.press(CHORD) + await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false') + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-stale-no-chip.png') + }) + + test('folder cards pass the chord through while another family has a chip', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + const folderKey = 'folder:child-toggle-folder' + await orcaPage.evaluate(() => { + const store = window.__store + const repo = store?.getState().repos[0] + if (!store || !repo) { + throw new Error('Missing seeded project') + } + store.setState({ + projectGroups: [ + { + id: 'child-toggle-group', + name: 'Folder project', + parentPath: repo.path, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + folderWorkspaces: [ + { + id: 'child-toggle-folder', + projectGroupId: 'child-toggle-group', + name: 'Folder without a child chip', + folderPath: repo.path, + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + ] + }) + }) + const folder = sidebarWorktreeRow(orcaPage, folderKey) + await expect(folder).toBeVisible() + await expect(folder.getByRole('button', { name: /child workspace/ })).toHaveCount(0) + await sidebarWorktreeRow(orcaPage, parentId).click() + await orcaPage.locator('[data-worktree-sidebar]').focus() + await folder.hover() + await observeShortcutClaim(orcaPage) + await orcaPage.keyboard.press(CHORD) + await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false') + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await folder.click() + await expect(folder).toHaveAttribute('aria-current', 'page') + await orcaPage.locator('[data-worktree-sidebar]').focus() + await movePointerOffSidebar(orcaPage) + await orcaPage.keyboard.press(CHORD) + await expect(orcaPage.locator('body')).toHaveAttribute('data-child-toggle-claimed', 'false') + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeVisible() + await captureEvidence(orcaPage, 'child-toggle-folder-pass-through.png') + }) + + test('pinned duplicates share the chip state and record the sidebar anchor', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + await orcaPage.evaluate(async (parentId) => { + await window.__store?.getState().updateSettings({ showPinnedWorktreesInGroups: true }) + window.__store?.setState((state) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [ + repoId, + rows.map((row) => (row.id === parentId ? { ...row, isPinned: true } : row)) + ]) + ) + })) + const sidebar = document.querySelector('[data-worktree-sidebar]') + sidebar?.addEventListener('orca-record-virtualized-scroll-anchor', () => { + sidebar.dataset.anchorRecords = String(Number(sidebar.dataset.anchorRecords ?? 0) + 1) + }) + }, parentId) + const chips = orcaPage.getByRole('button', { name: 'Hide 1 child workspace', exact: true }) + await expect(chips).toHaveCount(2) + const natural = orcaPage + .locator('[data-worktree-section-key="all"]') + .filter({ has: chips }) + .first() + await natural.hover() + await orcaPage.keyboard.press(CHORD) + await expect( + orcaPage.getByRole('button', { name: 'Show 1 child workspace', exact: true }) + ).toHaveCount(2) + await expect(orcaPage.locator('[data-worktree-sidebar]')).toHaveAttribute( + 'data-anchor-records', + '1' + ) + await expect(sidebarWorktreeRow(orcaPage, childId)).toBeHidden() + await orcaPage + .getByRole('button', { name: 'Show 1 child workspace', exact: true }) + .first() + .click() + await expect(chips).toHaveCount(2) + await expect(orcaPage.locator('[data-worktree-sidebar]')).toHaveAttribute( + 'data-anchor-records', + '2' + ) + await captureEvidence(orcaPage, 'child-toggle-pinned-chip-parity.png') + }) + + test('does nothing while a sidebar filter hides every child', async ({ orcaPage }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await setToggleBinding(orcaPage, ['Mod+Alt+H']) + await seedWorkspaceLiveTerminal(orcaPage, parentId) + const parentRow = sidebarWorktreeRow(orcaPage, parentId) + const childRow = sidebarWorktreeRow(orcaPage, childId) + await parentRow.click() + await expect(parentRow).toHaveAttribute('aria-current', 'page') + + const readCollapsedGroups = (): Promise => + orcaPage.evaluate(() => [...(window.__store?.getState().collapsedGroups ?? [])].sort()) + const setShowSleeping = (show: boolean): Promise => + orcaPage.evaluate((show) => window.__store?.getState().setShowSleepingWorkspaces(show), show) + + // The child has no live terminal, so hiding sleeping workspaces removes it and its chip. + await setShowSleeping(false) + try { + await expect(childRow).toBeHidden() + await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0) + const before = await readCollapsedGroups() + + // Checked after each press: two toggles of one key would cancel out. + await movePointerOffSidebar(orcaPage) + await orcaPage.keyboard.press(CHORD) + expect(await readCollapsedGroups()).toEqual(before) + await parentRow.hover() + await orcaPage.keyboard.press(CHORD) + expect(await readCollapsedGroups()).toEqual(before) + } finally { + await setShowSleeping(true) + } + await expect(childRow).toBeVisible() + }) +}) + +test('child-workspace shortcut and chip collapse survive an app restart', async ({ + testRepoPath +}, testInfo) => { + const session = createRestartSession(testInfo) + let app: Awaited>['app'] | undefined + try { + const first = await session.launch() + app = first.app + await attachRepoAndOpenTerminal(first.page, testRepoPath) + await waitForLineageWorktrees(first.page) + const family = await seedLineageScenario(first.page) + await setToggleBinding(first.page, ['Mod+Alt+H']) + await sidebarWorktreeRow(first.page, family.parentId).click() + await movePointerOffSidebar(first.page) + await first.page.keyboard.press(CHORD) + await expect(sidebarWorktreeRow(first.page, family.childId)).toBeHidden() + await session.close(app) + app = undefined + + const second = await session.launch() + app = second.app + await attachRepoAndOpenTerminal(second.page, testRepoPath) + await waitForLineageWorktrees(second.page) + // Resetting the grouping mode also clears the persisted collapsed groups. + await seedLineageScenario(second.page, { preserveGrouping: true }) + await expect( + sidebarWorktreeRow(second.page, family.parentId).getByRole('button', { + name: 'Show 1 child workspace' + }) + ).toBeVisible() + await expect(sidebarWorktreeRow(second.page, family.childId)).toBeHidden() + await movePointerOffSidebar(second.page) + await second.page.keyboard.press(CHORD) + await expect(sidebarWorktreeRow(second.page, family.childId)).toBeVisible() + await captureEvidence(second.page, 'child-toggle-restart-persistence.png') + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + } +})